diff --git a/.gitignore b/.gitignore index df991db82c..3424594092 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,10 @@ tests/*/vendor/* /tests/php-unit-tests/phpunit.xml /tests/php-unit-tests/postbuild_integration.xml +# CaptainHook: For Git hooks management +# - Never version local config file +/captainhook.config.json + # Jetbrains /.idea/** diff --git a/.make/git-hooks/README.md b/.make/git-hooks/README.md index 3e14d1deda..fc310be965 100644 --- a/.make/git-hooks/README.md +++ b/.make/git-hooks/README.md @@ -1,13 +1,16 @@ # Git hooks for iTop +> [!WARNING] +> This read me and the `install.php` / `pre-commit.php` files are outdated. If we were to keep using what it is supposed to do, we should migrate it to the proper CaptainHook Git hooks manager.\ + ## ❓ Goal -Those [git hooks](https://git-scm.com/docs/githooks) aims to ease developing on [iTop](https://github.com/Combodo/iTop). +~~Those [git hooks](https://git-scm.com/docs/githooks) aims to ease developing on [iTop](https://github.com/Combodo/iTop).~~ ## ☑ Available hooks -* pre-commit : rejects commit if you have at least one SCSS file staged, and no CSS file +* ~~pre-commit : rejects commit if you have at least one SCSS file staged, and no CSS file~~ ## ⚙ Install -Just run install.php ! +~~Just run install.php !~~ diff --git a/.make/git-hooks/pre-commit-php-code-style-fixer.php b/.make/git-hooks/pre-commit-php-code-style-fixer.php new file mode 100644 index 0000000000..a5aef94658 --- /dev/null +++ b/.make/git-hooks/pre-commit-php-code-style-fixer.php @@ -0,0 +1,87 @@ + $aChunk) { + $iChunkNumber = $iIdx + 1; + + $sChunkFilesAsArgs = implode(' ', array_map('escapeshellarg', $aChunk)); + $sPhpCsFixerCmd = escapeshellcmd(PHP_BINARY) + .' '.escapeshellarg($sPhpCsFixerBinaryAbsPath) + .' fix --using-cache=no --config='.escapeshellarg($sPhpCsFixerConfigFileAbsPath) + .' --verbose '.$sChunkFilesAsArgs; + + echo "Executing chunk {$iChunkNumber}/{$iNbChunks} : {$sPhpCsFixerCmd}\n\n"; + passthru($sPhpCsFixerCmd, $iExitCode); + if ($iExitCode !== 0) { + echo "Failed to fix chunk #{$iChunkNumber} Aborting.\n"; + exit($iExitCode); + } +} + +// Find which files have been fixed and re-stage them +$sFixedFilesCmd = 'git diff --name-only --diff-filter=M'; +exec($sFixedFilesCmd, $aFixedFiles); +$aFixedFilesToRestage = array_intersect($aFixedFiles, $aStagedFiles); + +// Re-stage fixed files to include them in the commit +if (count($aFixedFilesToRestage) === 0) { + echo "No file needed PHP code style fixing, it was already ok.\n"; + exit(0); +} + +echo "Re-staging fixed files:\n"; +foreach ($aFixedFilesToRestage as $sFixedFileToRestage) { + $sGitAddCmd = 'git add '.escapeshellarg($sFixedFileToRestage); + + echo " - {$sFixedFileToRestage}\n"; + passthru($sGitAddCmd, $iRetCode); + if ($iRetCode !== 0) { + echo " Failed to re-stage fixed file '{$sFixedFileToRestage}'. Continuing anyway.\n"; + } +} + +echo "All done, file(s) PHP code style fixed and added to commit.\n"; +exit($iExitCode); diff --git a/captainhook.config.json.dist b/captainhook.config.json.dist new file mode 100644 index 0000000000..8b85646714 --- /dev/null +++ b/captainhook.config.json.dist @@ -0,0 +1,9 @@ +// * Duplicate file into `captainhook.config.json` +// * Remove all comments (`// ...`) +// * Keep only 1 `php-path` line and adjust it to your environment +{ + // Example of Windows path to PHP binary + "php-path": "C:/wamp64/bin/php/php8.2.26/php.exe" + // Example of Unix path tp PHP binary + "php-path": "/usr/bin/php" +} \ No newline at end of file diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000000..250257fc46 --- /dev/null +++ b/captainhook.json @@ -0,0 +1,21 @@ +{ + "config" : { + "bootstrap" : "lib/autoload.php" + }, + "commit-msg" : { + "enabled" : true, + "actions" : [] + }, + "pre-commit" : { + "enabled" : true, + "actions" : [ + { + "action" : "{$CONFIG|value-of:php-path} .make/git-hooks/pre-commit-php-code-style-fixer.php", + "config" : { + "label" : "PHP code style fixer", + "allow-failure": false + } + } + ] + } +} diff --git a/composer.json b/composer.json index ec1d512445..0ccb5f363f 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "soundasleep/html2text": "~2.1" }, "require-dev": { + "captainhook/captainhook": "^5.25", "symfony/debug-bundle": "~6.4.0", "symfony/stopwatch": "~6.4.0", "symfony/web-profiler-bundle": "~6.4.0" @@ -98,8 +99,9 @@ } }, "scripts": { - "post-install-cmd": ["@rmUnnecessaryFolders", "@tcpdfCustomFonts"], - "post-update-cmd": ["@rmUnnecessaryFolders", "@tcpdfCustomFonts"], + "post-install-cmd": ["@installGitHooks","@rmUnnecessaryFolders", "@tcpdfCustomFonts"], + "post-update-cmd": ["@installGitHooks","@rmUnnecessaryFolders", "@tcpdfCustomFonts"], + "installGitHooks": "@php lib/bin/captainhook install --force", "rmUnnecessaryFolders": "@php .make/dependencies/rmUnnecessaryFolders.php --manager composer", "tcpdfCustomFonts": "@php .make/dependencies/composer/tcpdf/tcpdfUpdateFonts.php" } diff --git a/composer.lock b/composer.lock index d2d5c710ba..1e2dc2881c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0da5da3b165955f386268e6dd8db2a8d", + "content-hash": "9de096942ebd728704bc74b51221a5df", "packages": [ { "name": "apereo/phpcas", @@ -4880,6 +4880,322 @@ } ], "packages-dev": [ + { + "name": "captainhook/captainhook", + "version": "5.25.11", + "source": { + "type": "git", + "url": "https://github.com/captainhook-git/captainhook.git", + "reference": "f2278edde4b45af353861aae413fc3840515bb80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhook-git/captainhook/zipball/f2278edde4b45af353861aae413fc3840515bb80", + "reference": "f2278edde4b45af353861aae413fc3840515bb80", + "shasum": "" + }, + "require": { + "captainhook/secrets": "^0.9.4", + "ext-json": "*", + "ext-spl": "*", + "ext-xml": "*", + "php": ">=8.0", + "sebastianfeldmann/camino": "^0.9.2", + "sebastianfeldmann/cli": "^3.3", + "sebastianfeldmann/git": "^3.14", + "symfony/console": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/filesystem": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/process": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "sebastianfeldmann/captainhook": "*" + }, + "require-dev": { + "composer/composer": "~1 || ^2.0", + "mikey179/vfsstream": "~1" + }, + "bin": [ + "bin/captainhook" + ], + "type": "library", + "extra": { + "captainhook": { + "config": "captainhook.json" + }, + "branch-alias": { + "dev-main": "6.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "CaptainHook\\App\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git hook manager", + "homepage": "http://php.captainhook.info/", + "keywords": [ + "commit-msg", + "git", + "hooks", + "post-merge", + "pre-commit", + "pre-push", + "prepare-commit-msg" + ], + "support": { + "issues": "https://github.com/captainhook-git/captainhook/issues", + "source": "https://github.com/captainhook-git/captainhook/tree/5.25.11" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "time": "2025-08-12T12:14:57+00:00" + }, + { + "name": "captainhook/secrets", + "version": "0.9.7", + "source": { + "type": "git", + "url": "https://github.com/captainhook-git/secrets.git", + "reference": "d62c97f75f81ac98e22f1c282482bd35fa82f631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhook-git/secrets/zipball/d62c97f75f81ac98e22f1c282482bd35fa82f631", + "reference": "d62c97f75f81ac98e22f1c282482bd35fa82f631", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CaptainHook\\Secrets\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Utility classes to detect secrets", + "keywords": [ + "commit-msg", + "keys", + "passwords", + "post-merge", + "prepare-commit-msg", + "secrets", + "tokens" + ], + "support": { + "issues": "https://github.com/captainhook-git/secrets/issues", + "source": "https://github.com/captainhook-git/secrets/tree/0.9.7" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "time": "2025-04-08T07:10:48+00:00" + }, + { + "name": "sebastianfeldmann/camino", + "version": "0.9.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/camino.git", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/camino/zipball/bf2e4c8b2a029e9eade43666132b61331e3e8184", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Camino\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Path management the OO way", + "homepage": "https://github.com/sebastianfeldmann/camino", + "keywords": [ + "file system", + "path" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/camino/issues", + "source": "https://github.com/sebastianfeldmann/camino/tree/0.9.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2022-01-03T13:15:10+00:00" + }, + { + "name": "sebastianfeldmann/cli", + "version": "3.4.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/cli.git", + "reference": "6fa122afd528dae7d7ec988a604aa6c600f5d9b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/cli/zipball/6fa122afd528dae7d7ec988a604aa6c600f5d9b5", + "reference": "6fa122afd528dae7d7ec988a604aa6c600f5d9b5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "symfony/process": "^4.3 | ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Cli\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP cli helper classes", + "homepage": "https://github.com/sebastianfeldmann/cli", + "keywords": [ + "cli" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/cli/issues", + "source": "https://github.com/sebastianfeldmann/cli/tree/3.4.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2024-11-26T10:19:01+00:00" + }, + { + "name": "sebastianfeldmann/git", + "version": "3.15.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/git.git", + "reference": "90cb5a32f54dbb0d7dcd87d02e664ec2b50c0c96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/git/zipball/90cb5a32f54dbb0d7dcd87d02e664ec2b50c0c96", + "reference": "90cb5a32f54dbb0d7dcd87d02e664ec2b50c0c96", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "php": ">=8.0", + "sebastianfeldmann/cli": "^3.0" + }, + "require-dev": { + "mikey179/vfsstream": "^1.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Git\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git wrapper", + "homepage": "https://github.com/sebastianfeldmann/git", + "keywords": [ + "git" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/git/issues", + "source": "https://github.com/sebastianfeldmann/git/tree/3.15.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2025-09-05T08:07:09+00:00" + }, { "name": "symfony/debug-bundle", "version": "v6.4.0", @@ -4954,6 +5270,71 @@ ], "time": "2023-11-01T12:07:38+00:00" }, + { + "name": "symfony/process", + "version": "v6.4.26", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v6.4.26" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T09:57:09+00:00" + }, { "name": "symfony/stopwatch", "version": "v6.4.0", diff --git a/lib/autoload.php b/lib/autoload.php index 9ee03077e4..db2890200f 100644 --- a/lib/autoload.php +++ b/lib/autoload.php @@ -3,20 +3,23 @@ // autoload.php @generated by Composer if (PHP_VERSION_ID < 50600) { - if (!headers_sent()) { - header('HTTP/1.1 500 Internal Server Error'); - } - $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL; - if (!ini_get('display_errors')) { - if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { - fwrite(STDERR, $err); - } elseif (!headers_sent()) { - echo $err; - } - } - throw new RuntimeException($err); + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL; + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, $err); + } elseif (!headers_sent()) { + echo $err; + } + } + trigger_error( + $err, + E_USER_ERROR + ); } -require_once __DIR__ . '/composer/autoload_real.php'; +require_once __DIR__.'/composer/autoload_real.php'; return ComposerAutoloaderInit7f81b4a2a468a061c306af5e447a9a9f::getLoader(); diff --git a/lib/bin/captainhook b/lib/bin/captainhook new file mode 100644 index 0000000000..353a266dfd --- /dev/null +++ b/lib/bin/captainhook @@ -0,0 +1,119 @@ +#!/usr/bin/env php +realpath = realpath($opened_path) ?: $opened_path; + $opened_path = $this->realpath; + $this->handle = fopen($this->realpath, $mode); + $this->position = 0; + + return (bool) $this->handle; + } + + public function stream_read($count) + { + $data = fread($this->handle, $count); + + if ($this->position === 0) { + $data = preg_replace('{^#!.*\r?\n}', '', $data); + } + + $this->position += strlen($data); + + return $data; + } + + public function stream_cast($castAs) + { + return $this->handle; + } + + public function stream_close() + { + fclose($this->handle); + } + + public function stream_lock($operation) + { + return $operation ? flock($this->handle, $operation) : true; + } + + public function stream_seek($offset, $whence) + { + if (0 === fseek($this->handle, $offset, $whence)) { + $this->position = ftell($this->handle); + return true; + } + + return false; + } + + public function stream_tell() + { + return $this->position; + } + + public function stream_eof() + { + return feof($this->handle); + } + + public function stream_stat() + { + return array(); + } + + public function stream_set_option($option, $arg1, $arg2) + { + return true; + } + + public function url_stat($path, $flags) + { + $path = substr($path, 17); + if (file_exists($path)) { + return stat($path); + } + + return false; + } + } + } + + if ( + (function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true)) + || (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper')) + ) { + return include("phpvfscomposer://" . __DIR__ . '/..'.'/captainhook/captainhook/bin/captainhook'); + } +} + +return include __DIR__ . '/..'.'/captainhook/captainhook/bin/captainhook'; diff --git a/lib/bin/captainhook.bat b/lib/bin/captainhook.bat new file mode 100644 index 0000000000..d2b9b59998 --- /dev/null +++ b/lib/bin/captainhook.bat @@ -0,0 +1,5 @@ +@ECHO OFF +setlocal DISABLEDELAYEDEXPANSION +SET BIN_TARGET=%~dp0/captainhook +SET COMPOSER_RUNTIME_BIN_DIR=%~dp0 +php "%BIN_TARGET%" %* diff --git a/lib/captainhook/captainhook/LICENSE b/lib/captainhook/captainhook/LICENSE new file mode 100644 index 0000000000..3c01e7bb05 --- /dev/null +++ b/lib/captainhook/captainhook/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Sebastian Feldmann + +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/lib/captainhook/captainhook/bin/captainhook b/lib/captainhook/captainhook/bin/captainhook new file mode 100644 index 0000000000..b8d6ce01c8 --- /dev/null +++ b/lib/captainhook/captainhook/bin/captainhook @@ -0,0 +1,88 @@ +#!/usr/bin/env php + + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + */ + +use CaptainHook\App\Console\Application as CaptainHook; +use Symfony\Component\Console\Input\ArgvInput; + +(static function($argv) +{ + define('__CAPTAINHOOK_RUNNING__', true); + + // check installation type [composer bin dir, git clone / phar, composer package dir] + $composerAutoloadLocations = [ + __DIR__ . '/../autoload.php', + __DIR__ . '/../vendor/autoload.php', + __DIR__ . '/../../../autoload.php' + ]; + + foreach ($composerAutoloadLocations as $file) { + if (file_exists($file)) { + define('CAPTAINHOOK_COMPOSER_AUTOLOAD', $file); + break; + } + } + unset($file); + + if (!defined('CAPTAINHOOK_COMPOSER_AUTOLOAD')) { + fwrite(STDERR, + 'Autoloader could not be found:' . PHP_EOL . + ' Please run `composer install` to generate the autoloader' . PHP_EOL + ); + exit(1); + } + + $GLOBALS['__composer_autoload_files'] = []; + + try { + require CAPTAINHOOK_COMPOSER_AUTOLOAD; + } catch (Throwable $exception) { + fwrite(STDERR, + 'Composer autoloader crashed:' . PHP_EOL . + ' Please update your autoloader by running `composer install`' . PHP_EOL . + ' You can re-run the hook by executing `' . implode(' ', $argv) . '`' . PHP_EOL + ); + exit(1); + } + + $captainHook = new CaptainHook($argv[0]); + $captainHook->run(new ArgvInput($argv)); +} +)($argv); + + diff --git a/lib/captainhook/captainhook/composer.json b/lib/captainhook/captainhook/composer.json new file mode 100644 index 0000000000..6b67f08be0 --- /dev/null +++ b/lib/captainhook/captainhook/composer.json @@ -0,0 +1,77 @@ +{ + "name": "captainhook/captainhook", + "type": "library", + "description": "PHP git hook manager", + "keywords": ["git", "hooks", "pre-commit", "pre-push", "commit-msg", "prepare-commit-msg", "post-merge"], + "homepage": "http://php.captainhook.info/", + "license": "MIT", + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "support": { + "issues": "https://github.com/captainhook-git/captainhook/issues" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sebastianfeldmann" + } + ], + "autoload": { + "psr-4": { + "CaptainHook\\App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CaptainHook\\App\\": "tests/unit/", + "CaptainHook\\App\\Integration\\": "tests/integration/" + } + }, + "require": { + "php": ">=8.0", + "ext-json": "*", + "ext-spl": "*", + "ext-xml": "*", + "captainhook/secrets": "^0.9.4", + "sebastianfeldmann/camino": "^0.9.2", + "sebastianfeldmann/cli": "^3.3", + "sebastianfeldmann/git": "^3.14", + "symfony/console": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/filesystem": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/process": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "require-dev": { + "composer/composer": "~1 || ^2.0", + "mikey179/vfsstream": "~1" + }, + "bin": [ + "bin/captainhook" + ], + "extra": { + "branch-alias": { + "dev-main": "6.0.x-dev" + }, + "captainhook": { + "config": "captainhook.json" + } + }, + "replace" : { + "sebastianfeldmann/captainhook": "*" + }, + "config": { + "sort-packages": true + }, + "scripts": { + "post-install-cmd": "tools/phive install --force-accept-unsigned", + "tools": "tools/phive install --force-accept-unsigned", + "compile": "tools/box compile", + "test": "tools/phpunit --testsuite UnitTests", + "test:integration": "tools/phpunit --testsuite IntegrationTests --no-coverage", + "static": "tools/phpstan analyse", + "style": "tools/phpcs --standard=psr12 src tests" + } +} diff --git a/lib/captainhook/captainhook/phive.xml b/lib/captainhook/captainhook/phive.xml new file mode 100644 index 0000000000..0b19e449c1 --- /dev/null +++ b/lib/captainhook/captainhook/phive.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/lib/captainhook/captainhook/phpcs.xml b/lib/captainhook/captainhook/phpcs.xml new file mode 100644 index 0000000000..8499fa39ed --- /dev/null +++ b/lib/captainhook/captainhook/phpcs.xml @@ -0,0 +1,10 @@ + + + + + + + + src + tests + diff --git a/lib/captainhook/captainhook/src/CH.php b/lib/captainhook/captainhook/src/CH.php new file mode 100644 index 0000000000..126c40870b --- /dev/null +++ b/lib/captainhook/captainhook/src/CH.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App; + +/** + * Class CH + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +final class CH +{ + /** + * Current CaptainHook version + */ + public const VERSION = '5.25.11'; + + /** + * Release date of the current version + */ + public const RELEASE_DATE = '2025-08-12'; + + /** + * Default configuration file + */ + public const CONFIG = 'captainhook.json'; + + /** + * Minimal required version for the installer + */ + public const MIN_REQ_INSTALLER = '5.22.0'; +} diff --git a/lib/captainhook/captainhook/src/Config.php b/lib/captainhook/captainhook/src/Config.php new file mode 100644 index 0000000000..88843eaa62 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config.php @@ -0,0 +1,449 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App; + +use CaptainHook\App\Config\Run; +use InvalidArgumentException; +use SebastianFeldmann\Camino\Check; + +/** + * Class Config + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + * @internal + */ +class Config +{ + public const SETTING_ALLOW_FAILURE = 'allow-failure'; + public const SETTING_BOOTSTRAP = 'bootstrap'; + public const SETTING_COLORS = 'ansi-colors'; + public const SETTING_CUSTOM = 'custom'; + public const SETTING_GIT_DIR = 'git-directory'; + public const SETTING_INCLUDES = 'includes'; + public const SETTING_INCLUDES_LEVEL = 'includes-level'; + public const SETTING_LABEL = 'label'; + public const SETTING_RUN_EXEC = 'run-exec'; + public const SETTING_RUN_MODE = 'run-mode'; + public const SETTING_RUN_PATH = 'run-path'; + public const SETTING_RUN_GIT = 'run-git'; + public const SETTING_PHP_PATH = 'php-path'; + public const SETTING_VERBOSITY = 'verbosity'; + public const SETTING_FAIL_ON_FIRST_ERROR = 'fail-on-first-error'; + + /** + * Path to the config file + * + * @var string + */ + private string $path; + + /** + * Does the config file exist + * + * @var bool + */ + private bool $fileExists; + + /** + * CaptainHook settings + * + * @var array + */ + private array $settings; + + /** + * All options related to running CaptainHook + * + * @var \CaptainHook\App\Config\Run + */ + private Run $runConfig; + + /** + * List of users custom settings + * + * @var array + */ + private array $custom = []; + + /** + * List of plugins + * + * @var array + */ + private array $plugins = []; + + /** + * List of hook configs + * + * @var array + */ + private array $hooks = []; + + /** + * Config constructor + * + * @param string $path + * @param bool $fileExists + * @param array $settings + */ + public function __construct(string $path, bool $fileExists = false, array $settings = []) + { + $settings = $this->setupPlugins($settings); + $settings = $this->setupCustom($settings); + $settings = $this->setupRunConfig($settings); + + + $this->path = $path; + $this->fileExists = $fileExists; + $this->settings = $settings; + + foreach (Hooks::getValidHooks() as $hook => $value) { + $this->hooks[$hook] = new Config\Hook($hook); + } + } + + /** + * Extract custom settings from Captain Hook ones + * + * @param array $settings + * @return array + */ + private function setupCustom(array $settings): array + { + /* @var array $custom */ + $this->custom = $settings['custom'] ?? []; + unset($settings['custom']); + + return $settings; + } + + /** + * Setup all configured plugins + * + * @param array $settings + * @return array + */ + private function setupPlugins(array $settings): array + { + /* @var array> $pluginSettings */ + $pluginSettings = $settings['plugins'] ?? []; + unset($settings['plugins']); + + foreach ($pluginSettings as $plugin) { + $name = (string) $plugin['plugin']; + $options = isset($plugin['options']) && is_array($plugin['options']) + ? $plugin['options'] + : []; + $this->plugins[$name] = new Config\Plugin($name, $options); + } + return $settings; + } + + /** + * Extract all running related settings into a run configuration + * + * @param array $settings + * @return array + */ + private function setupRunConfig(array $settings): array + { + // extract the legacy settings + $settingsToMove = [ + self::SETTING_RUN_MODE, + self::SETTING_RUN_EXEC, + self::SETTING_RUN_PATH, + self::SETTING_RUN_GIT + ]; + $config = []; + foreach ($settingsToMove as $setting) { + if (!empty($settings[$setting])) { + $config[substr($setting, 4)] = $settings[$setting]; + } + unset($settings[$setting]); + } + // make sure the new run configuration supersedes the legacy settings + if (isset($settings['run']) && is_array($settings['run'])) { + $config = array_merge($config, $settings['run']); + unset($settings['run']); + } + $this->runConfig = new Run($config); + return $settings; + } + + /** + * Is configuration loaded from file + * + * @return bool + */ + public function isLoadedFromFile(): bool + { + return $this->fileExists; + } + + /** + * Are actions allowed to fail without stopping the git operation + * + * @return bool + */ + public function isFailureAllowed(): bool + { + return (bool) ($this->settings[self::SETTING_ALLOW_FAILURE] ?? false); + } + + /** + * @param string $hook + * @param bool $withVirtual if true, also check if hook is enabled through any enabled virtual hook + * @return bool + */ + public function isHookEnabled(string $hook, bool $withVirtual = true): bool + { + // either this hook is explicitly enabled + $hookConfig = $this->getHookConfig($hook); + if ($hookConfig->isEnabled()) { + return true; + } + + // or any virtual hook that triggers it is enabled + if ($withVirtual && Hooks::triggersVirtualHook($hookConfig->getName())) { + $virtualHookConfig = $this->getHookConfig(Hooks::getVirtualHook($hookConfig->getName())); + if ($virtualHookConfig->isEnabled()) { + return true; + } + } + + return false; + } + + /** + * Path getter + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Return git directory path if configured, CWD/.git if not + * + * @return string + */ + public function getGitDirectory(): string + { + if (empty($this->settings[self::SETTING_GIT_DIR])) { + return getcwd() . '/.git'; + } + + // if repo path is absolute use it otherwise create an absolute path relative to the configuration file + return Check::isAbsolutePath($this->settings[self::SETTING_GIT_DIR]) + ? $this->settings[self::SETTING_GIT_DIR] + : dirname($this->path) . '/' . $this->settings[self::SETTING_GIT_DIR]; + } + + /** + * Return bootstrap file if configured, CWD/vendor/autoload.php by default + * + * @param string $default + * @return string + */ + public function getBootstrap(string $default = 'vendor/autoload.php'): string + { + return !empty($this->settings[self::SETTING_BOOTSTRAP]) + ? $this->settings[self::SETTING_BOOTSTRAP] + : $default; + } + + /** + * Return the configured verbosity + * + * @return string + */ + public function getVerbosity(): string + { + return !empty($this->settings[self::SETTING_VERBOSITY]) + ? $this->settings[self::SETTING_VERBOSITY] + : 'normal'; + } + + /** + * Should the output use ansi colors + * + * @return bool + */ + public function useAnsiColors(): bool + { + return (bool) ($this->settings[self::SETTING_COLORS] ?? true); + } + + /** + * Get configured php-path + * + * @return string + */ + public function getPhpPath(): string + { + return (string) ($this->settings[self::SETTING_PHP_PATH] ?? ''); + } + + /** + * Get run configuration + * + * @return \CaptainHook\App\Config\Run + */ + public function getRunConfig(): Run + { + return $this->runConfig; + } + + /** + * Returns the users custom config values + * + * @return array + */ + public function getCustomSettings(): array + { + return $this->custom; + } + + /** + * Whether to abort the hook as soon as a any action has errored. Default is true. + * Otherwise, all actions get executed (even if some of them have failed) and + * finally, a non-zero exit code is returned if any action has errored. + * + * @return bool + */ + public function failOnFirstError(): bool + { + return (bool) ($this->settings[self::SETTING_FAIL_ON_FIRST_ERROR] ?? true); + } + + /** + * Return config for given hook + * + * @param string $hook + * @return \CaptainHook\App\Config\Hook + * @throws \InvalidArgumentException + */ + public function getHookConfig(string $hook): Config\Hook + { + if (!Hook\Util::isValid($hook)) { + throw new InvalidArgumentException('Invalid hook name: ' . $hook); + } + return $this->hooks[$hook]; + } + + /** + * Return hook configs + * + * @return array + */ + public function getHookConfigs(): array + { + return $this->hooks; + } + + /** + * Returns a hook config containing all the actions to execute + * + * Returns all actions from the triggered hook but also any actions of virtual hooks that might be triggered. + * E.g. 'post-rewrite' or 'post-checkout' trigger the virtual/artificial 'post-change' hook. + * Virtual hooks are special hooks to simplify configuration. + * + * @param string $hook + * @return \CaptainHook\App\Config\Hook + */ + public function getHookConfigToExecute(string $hook): Config\Hook + { + $config = new Config\Hook($hook, true); + $hookConfig = $this->getHookConfig($hook); + $config->addAction(...$hookConfig->getActions()); + if (Hooks::triggersVirtualHook($hookConfig->getName())) { + $vHookConfig = $this->getHookConfig(Hooks::getVirtualHook($hookConfig->getName())); + if ($vHookConfig->isEnabled()) { + $config->addAction(...$vHookConfig->getActions()); + } + } + return $config; + } + + /** + * Return plugins + * + * @return Config\Plugin[] + */ + public function getPlugins(): array + { + return $this->plugins; + } + + /** + * Return config array to write to disc + * + * @return array + */ + public function getJsonData(): array + { + $data = []; + $config = $this->getConfigJsonData(); + + if (!empty($config)) { + $data['config'] = $config; + } + + foreach (Hooks::getValidHooks() as $hook => $value) { + if ($this->hooks[$hook]->isEnabled() || $this->hooks[$hook]->hasActions()) { + $data[$hook] = $this->hooks[$hook]->getJsonData(); + } + } + return $data; + } + + /** + * Build the "config" JSON section of the configuration file + * + * @return array + */ + private function getConfigJsonData(): array + { + $config = !empty($this->settings) ? $this->settings : []; + + $runConfigData = $this->runConfig->getJsonData(); + if (!empty($runConfigData)) { + $config['run'] = $runConfigData; + } + if (!empty($this->plugins)) { + $config['plugins'] = $this->getPluginsJsonData(); + } + if (!empty($this->custom)) { + $config['custom'] = $this->custom; + } + return $config; + } + + /** + * Collect and return plugin json data for all plugins + * + * @return array + */ + private function getPluginsJsonData(): array + { + $plugins = []; + foreach ($this->plugins as $plugin) { + $plugins[] = $plugin->getJsonData(); + } + return $plugins; + } +} diff --git a/lib/captainhook/captainhook/src/Config/Action.php b/lib/captainhook/captainhook/src/Config/Action.php new file mode 100644 index 0000000000..f04e577ef8 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Action.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +use CaptainHook\App\Config; + +/** + * Class Action + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Action +{ + /** + * Action php class, php static method, or cli script + * + * @var string + */ + private string $action; + + /** + * Map of options name => value + * + * @var \CaptainHook\App\Config\Options + */ + private Options $options; + + /** + * List of action conditions + * + * @var \CaptainHook\App\Config\Condition[] + */ + private array $conditions = []; + + /** + * Action settings + * + * @var array + */ + private array $settings = []; + + /** + * List of available settings + * + * @var string[] + */ + private static array $availableSettings = [ + Config::SETTING_ALLOW_FAILURE, + Config::SETTING_LABEL + ]; + + /** + * Indicates if an action config was included from another file + * + * @var bool + */ + private bool $isIncluded = false; + + /** + * Action constructor + * + * @param string $action + * @param array $options + * @param array $conditions + * @param array $settings + */ + public function __construct(string $action, array $options = [], array $conditions = [], array $settings = []) + { + $this->action = $action; + $this->setupOptions($options); + $this->setupConditions($conditions); + $this->setupSettings($settings); + } + + /** + * Setup options + * + * @param array $options + */ + private function setupOptions(array $options): void + { + $this->options = new Options($options); + } + + /** + * Setup action conditions + * + * @param array> $conditions + */ + private function setupConditions(array $conditions): void + { + foreach ($conditions as $condition) { + $this->conditions[] = new Condition($condition['exec'], $condition['args'] ?? []); + } + } + + /** + * Setting up the action settings + * + * @param array $settings + * @return void + */ + private function setupSettings(array $settings): void + { + foreach (self::$availableSettings as $setting) { + if (isset($settings[$setting])) { + $this->settings[$setting] = $settings[$setting]; + } + } + } + + /** + * Marks a action config as included + * + * @return void + */ + public function markIncluded(): void + { + $this->isIncluded = true; + } + + /** + * Check if an action config was included + * + * @return bool + */ + public function isIncluded(): bool + { + return $this->isIncluded; + } + + /** + * Indicates if the action can fail without stopping the git operation + * + * @param bool $default + * @return bool + */ + public function isFailureAllowed(bool $default = false): bool + { + return (bool) ($this->settings[Config::SETTING_ALLOW_FAILURE] ?? $default); + } + + /** + * Return the label or the action if no label is set + * + * @return string + */ + public function getLabel(): string + { + return (string) ($this->settings[Config::SETTING_LABEL] ?? $this->getAction()); + } + + /** + * Action getter + * + * @return string + */ + public function getAction(): string + { + return $this->action; + } + + /** + * Return option map + * + * @return \CaptainHook\App\Config\Options + */ + public function getOptions(): Options + { + return $this->options; + } + + /** + * Return condition configurations + * + * @return \CaptainHook\App\Config\Condition[] + */ + public function getConditions(): array + { + return $this->conditions; + } + + /** + * Return config data + * + * @return array + */ + public function getJsonData(): array + { + $data = [ + 'action' => $this->action + ]; + + $options = $this->options->getAll(); + if (!empty($options)) { + $data['options'] = $options; + } + + $conditions = $this->getConditionJsonData(); + if (!empty($conditions)) { + $data['conditions'] = $conditions; + } + + if (!empty($this->settings)) { + $data['config'] = $this->settings; + } + + return $data; + } + + /** + * Return conditions json data + * + * @return array + */ + private function getConditionJsonData(): array + { + $json = []; + foreach ($this->conditions as $condition) { + $json[] = $condition->getJsonData(); + } + return $json; + } +} diff --git a/lib/captainhook/captainhook/src/Config/Condition.php b/lib/captainhook/captainhook/src/Config/Condition.php new file mode 100644 index 0000000000..910817a811 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Condition.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +/** + * Class Action + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + * @internal + */ +class Condition +{ + /** + * Condition executable + * + * @var string + */ + private string $exec; + + /** + * Condition arguments + * + * @var array + */ + private array $args; + + /** + * Condition constructor + * + * @param string $exec + * @param array $args + */ + public function __construct(string $exec, array $args = []) + { + $this->exec = $exec; + $this->args = $args; + } + + /** + * Exec getter + * + * @return string + */ + public function getExec(): string + { + return $this->exec; + } + + /** + * Args getter + * + * @return array + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * Return config data + * + * @return array + */ + public function getJsonData(): array + { + return [ + 'exec' => $this->exec, + 'args' => $this->args, + ]; + } +} diff --git a/lib/captainhook/captainhook/src/Config/Factory.php b/lib/captainhook/captainhook/src/Config/Factory.php new file mode 100644 index 0000000000..a452829885 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Factory.php @@ -0,0 +1,347 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +use CaptainHook\App\CH; +use CaptainHook\App\Config; +use CaptainHook\App\Hook\Util as HookUtil; +use CaptainHook\App\Storage\File\Json; +use RuntimeException; + +/** + * Class Factory + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + * @internal + */ +final class Factory +{ + /** + * Maximal level in including config files + * + * @var int + */ + private int $maxIncludeLevel = 1; + + /** + * Current level of inclusion + * + * @var int + */ + private int $includeLevel = 0; + + /** + * Create a CaptainHook configuration + * + * @param string $path Path to the configuration file + * @param array $settings Settings passed as options on the command line + * @return \CaptainHook\App\Config + * @throws \Exception + */ + public function createConfig(string $path = '', array $settings = []): Config + { + $path = $path ?: getcwd() . DIRECTORY_SEPARATOR . CH::CONFIG; + $file = new Json($path); + $settings = $this->combineArgumentsAndSettingFile($file, $settings); + + return $this->setupConfig($file, $settings); + } + + /** + * Read settings from a local 'config' file + * + * If you prefer a different verbosity or use a different run mode locally then your teammates do. + * You can create a 'captainhook.config.json' in the same directory as your captainhook + * configuration file and use it to overwrite the 'config' settings of that configuration file. + * Exclude the 'captainhook.config.json' from version control, and you don't have to edit the + * version controlled configuration for your local specifics anymore. + * + * Settings provided as arguments still overrule config file settings: + * + * ARGUMENTS > SETTINGS_FILE > CONFIGURATION + * + * @param \CaptainHook\App\Storage\File\Json $file + * @param array $settings + * @return array + */ + private function combineArgumentsAndSettingFile(Json $file, array $settings): array + { + $settingsFile = new Json(dirname($file->getPath()) . '/captainhook.config.json'); + if ($settingsFile->exists()) { + $fileSettings = $settingsFile->readAssoc(); + $settings = array_merge($fileSettings, $settings); + } + return $settings; + } + + /** + * Includes an external captainhook configuration + * + * @param string $path + * @return \CaptainHook\App\Config + * @throws \Exception + */ + private function includeConfig(string $path): Config + { + $file = new Json($path); + if (!$file->exists()) { + throw new RuntimeException('Config to include not found: ' . $path); + } + return $this->setupConfig($file); + } + + /** + * Return a configuration with data loaded from json file if it exists + * + * @param \CaptainHook\App\Storage\File\Json $file + * @param array $settings + * @return \CaptainHook\App\Config + * @throws \Exception + */ + private function setupConfig(Json $file, array $settings = []): Config + { + return $file->exists() + ? $this->loadConfigFromFile($file, $settings) + : new Config($file->getPath(), false, $settings); + } + + /** + * Loads a given file into given the configuration + * + * @param \CaptainHook\App\Storage\File\Json $file + * @param array $settings + * @return \CaptainHook\App\Config + * @throws \Exception + */ + private function loadConfigFromFile(Json $file, array $settings): Config + { + $json = $file->readAssoc(); + Util::validateJsonConfiguration($json); + + $settings = Util::mergeSettings($this->extractSettings($json), $settings); + $config = new Config($file->getPath(), true, $settings); + if (!empty($settings)) { + $json['config'] = $settings; + } + + $this->appendIncludedConfigurations($config, $json); + + foreach (HookUtil::getValidHooks() as $hook => $class) { + if (isset($json[$hook])) { + $this->configureHook($config->getHookConfig($hook), $json[$hook]); + } + } + + $this->validatePhpPath($config); + return $config; + } + + /** + * Return the `config` section of some json + * + * @param array $json + * @return array + */ + private function extractSettings(array $json): array + { + return Util::extractListFromJson($json, 'config'); + } + + /** + * Returns the `conditions` section of an actionJson + * + * @param array $json + * @return array + */ + private function extractConditions(mixed $json): array + { + return Util::extractListFromJson($json, 'conditions'); + } + + /** + * Returns the `options` section af some json + * + * @param array $json + * @return array + */ + private function extractOptions(mixed $json): array + { + return Util::extractListFromJson($json, 'options'); + } + + /** + * Set up a hook configuration by json data + * + * @param \CaptainHook\App\Config\Hook $config + * @param array $json + * @return void + * @throws \Exception + */ + private function configureHook(Config\Hook $config, array $json): void + { + $config->setEnabled($json['enabled'] ?? true); + foreach ($json['actions'] as $actionJson) { + $options = $this->extractOptions($actionJson); + $conditions = $this->extractConditions($actionJson); + $settings = $this->extractSettings($actionJson); + $config->addAction(new Config\Action($actionJson['action'], $options, $conditions, $settings)); + } + } + + /** + * Makes sure the configured PHP executable exists + * + * @param \CaptainHook\App\Config $config + * @return void + */ + private function validatePhpPath(Config $config): void + { + if (empty($config->getPhpPath())) { + return; + } + $pathToCheck = [$config->getPhpPath()]; + $parts = explode(' ', $config->getPhpPath()); + // if there are spaces in the php-path and they are not escaped + // it looks like an executable is used to find the PHP binary + // so at least check if the executable exists + if ($this->usesPathResolver($parts)) { + $pathToCheck[] = $parts[0]; + } + + foreach ($pathToCheck as $path) { + if (file_exists($path)) { + return; + } + } + throw new RuntimeException('The configured php-path is wrong: ' . $config->getPhpPath()); + } + + /** + * Is a binary used to resolve the php path + * + * @param array $parts + * @return bool + */ + private function usesPathResolver(array $parts): bool + { + return count($parts) > 1 && !str_ends_with($parts[0], '\\'); + } + + /** + * Append all included configuration to the current configuration + * + * @param \CaptainHook\App\Config $config + * @param array $json + * @throws \Exception + */ + private function appendIncludedConfigurations(Config $config, array $json): void + { + $this->readMaxIncludeLevel($json); + + if ($this->includeLevel < $this->maxIncludeLevel) { + $this->includeLevel++; + $includes = $this->loadIncludedConfigs($json, $config->getPath()); + foreach (HookUtil::getValidHooks() as $hook => $class) { + $this->mergeHookConfigFromIncludes($config->getHookConfig($hook), $includes); + } + $this->includeLevel--; + } + } + + /** + * Check config section for 'includes-level' setting + * + * @param array $json + */ + private function readMaxIncludeLevel(array $json): void + { + // read the include-level setting only for the actual configuration + if ($this->includeLevel === 0 && isset($json['config'][Config::SETTING_INCLUDES_LEVEL])) { + $this->maxIncludeLevel = (int) $json['config'][Config::SETTING_INCLUDES_LEVEL]; + } + } + + /** + * Merge a given hook config with the corresponding hook configs from a list of included configurations + * + * @param \CaptainHook\App\Config\Hook $hook + * @param \CaptainHook\App\Config[] $includes + * @return void + */ + private function mergeHookConfigFromIncludes(Hook $hook, array $includes): void + { + foreach ($includes as $includedConfig) { + $includedHook = $includedConfig->getHookConfig($hook->getName()); + if ($includedHook->isEnabled()) { + $hook->setEnabled(true); + // This `setEnable` is solely to overwrite the main configuration in the special case that the hook + // is not configured at all. In this case the empty config is disabled by default, and adding an + // empty hook config just to enable the included actions feels a bit dull. + // Since the main hook is processed last (if one is configured) the enabled flag will be overwritten + // once again by the main config value. This is to make sure that if somebody disables a hook in its + // main configuration, no actions will get executed, even if we have enabled hooks in any include file. + $this->copyActionsFromTo($includedHook, $hook); + } + } + } + + /** + * Return list of included configurations to add them to the main configuration afterwards + * + * @param array $json + * @param string $path + * @return \CaptainHook\App\Config[] + * @throws \Exception + */ + protected function loadIncludedConfigs(array $json, string $path): array + { + $includes = []; + $directory = dirname($path); + $files = Util::extractListFromJson(Util::extractListFromJson($json, 'config'), Config::SETTING_INCLUDES); + + foreach ($files as $file) { + $includes[] = $this->includeConfig($directory . DIRECTORY_SEPARATOR . $file); + } + return $includes; + } + + /** + * Copy action from a given configuration to the second given configuration + * + * @param \CaptainHook\App\Config\Hook $sourceConfig + * @param \CaptainHook\App\Config\Hook $targetConfig + */ + private function copyActionsFromTo(Hook $sourceConfig, Hook $targetConfig): void + { + foreach ($sourceConfig->getActions() as $action) { + $action->markIncluded(); + $targetConfig->addAction($action); + } + } + + /** + * Config factory method + * + * @param string $path + * @param array $settings + * @return \CaptainHook\App\Config + * @throws \Exception + */ + public static function create(string $path = '', array $settings = []): Config + { + $factory = new static(); + return $factory->createConfig($path, $settings); + } +} diff --git a/lib/captainhook/captainhook/src/Config/Hook.php b/lib/captainhook/captainhook/src/Config/Hook.php new file mode 100644 index 0000000000..8c515512cf --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Hook.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +/** + * Class Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + * @internal + */ +class Hook +{ + /** + * Hook name e.g. pre-commit + * + * @var string + */ + private string $name; + + /** + * Is hook enabled + * + * @var bool + */ + private bool $isEnabled; + + /** + * List of Actions + * + * @var \CaptainHook\App\Config\Action[] + */ + private $actions = []; + + /** + * Hook constructor + * + * @param string $name + * @param bool $enabled + */ + public function __construct(string $name, bool $enabled = false) + { + $this->name = $name; + $this->isEnabled = $enabled; + } + + /** + * Name getter + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Enable or disable the hook + * + * @param bool $enabled + * @return void + */ + public function setEnabled(bool $enabled): void + { + $this->isEnabled = $enabled; + } + + /** + * Is this hook enabled + * + * @return bool + */ + public function isEnabled(): bool + { + return $this->isEnabled; + } + + /** + * Check if a hook config has actions + * + * @return bool + */ + public function hasActions(): bool + { + return !empty($this->actions); + } + + /** + * Add an action to the list + * + * @param \CaptainHook\App\Config\Action ...$actions + * @return void + */ + public function addAction(Action ...$actions): void + { + foreach ($actions as $action) { + $this->actions[] = $action; + } + } + + /** + * Return the action list + * + * @return \CaptainHook\App\Config\Action[] + */ + public function getActions(): array + { + return $this->actions; + } + + /** + * Return config data + * + * @return array + */ + public function getJsonData(): array + { + $config = ['enabled' => $this->isEnabled, 'actions' => []]; + foreach ($this->actions as $action) { + if (!$action->isIncluded()) { + $config['actions'][] = $action->getJsonData(); + } + } + return $config; + } +} diff --git a/lib/captainhook/captainhook/src/Config/Options.php b/lib/captainhook/captainhook/src/Config/Options.php new file mode 100644 index 0000000000..f23dd9f981 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Options.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +/** + * Class Options + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.0.0 + */ +class Options +{ + /** + * Map of options + * + * @var array + */ + private array $options; + + /** + * Options constructor + * + * @param array $options + */ + public function __construct(array $options) + { + $this->options = $options; + } + + /** + * Return a option value + * + * @template ProvidedDefault + * @param string $name + * @param ProvidedDefault $default + * @return ProvidedDefault|mixed + */ + public function get(string $name, $default = null) + { + return $this->options[$name] ?? $default; + } + + /** + * Return all options + * + * @return array + */ + public function getAll(): array + { + return $this->options; + } +} diff --git a/lib/captainhook/captainhook/src/Config/Plugin.php b/lib/captainhook/captainhook/src/Config/Plugin.php new file mode 100644 index 0000000000..b1322a2609 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Plugin.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +use CaptainHook\App\Exception\InvalidPlugin; +use CaptainHook\App\Plugin\CaptainHook; + +/** + * Class Plugin + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.9.0 + */ +class Plugin +{ + /** + * Plugin class + * + * @var string + */ + private string $plugin; + + /** + * Map of options name => value + * + * @var Options + */ + private Options $options; + + /** + * Plugin constructor + * + * @param string $plugin + * @param array $options + */ + public function __construct(string $plugin, array $options = []) + { + if (!is_a($plugin, CaptainHook::class, true)) { + throw new InvalidPlugin("{$plugin} is not a valid CaptainHook plugin."); + } + + $this->plugin = $plugin; + $this->setupOptions($options); + } + + /** + * Setup options + * + * @param array $options + */ + private function setupOptions(array $options): void + { + $this->options = new Options($options); + } + + /** + * Plugin class name getter + * + * @return string + */ + public function getPlugin(): string + { + return $this->plugin; + } + + /** + * Return option map + * + * @return Options + */ + public function getOptions(): Options + { + return $this->options; + } + + /** + * Return config data + * + * @return array + */ + public function getJsonData(): array + { + return [ + 'plugin' => $this->plugin, + 'options' => $this->options->getAll(), + ]; + } +} diff --git a/lib/captainhook/captainhook/src/Config/Run.php b/lib/captainhook/captainhook/src/Config/Run.php new file mode 100644 index 0000000000..32175bac41 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Run.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +/** + * Run Config + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.18.0 + */ +class Run +{ + private const MODE = 'mode'; + private const PATH = 'path'; + private const EXEC = 'exec'; + private const GIT = 'git'; + + /** + * Map of options name => value + * + * @var \CaptainHook\App\Config\Options + */ + private Options $options; + + /** + * Run constructor + * + * @param array $options + */ + public function __construct(array $options = []) + { + $this->setupOptions($options); + } + + /** + * Setup options + * + * @param array $options + */ + private function setupOptions(array $options): void + { + $this->options = new Options($options); + } + + /** + * Return the run mode shell|docker|php|local|wsl + * + * @return string + */ + public function getMode(): string + { + return $this->options->get(self::MODE, 'shell'); + } + + /** + * Return the path to the captain from within the container or to overwrite symlink resolution + * + * Since realpath() returns the real absolute path and not the absolute symlink path this + * setting could be used to overwrite this behaviour. + * + * @return string + */ + public function getCaptainsPath(): string + { + return $this->options->get(self::PATH, ''); + } + + /** + * Return the docker command to use to execute the captain + * + * @return string + */ + public function getDockerCommand(): string + { + return $this->options->get(self::EXEC, ''); + } + + /** + * Return the path mapping setting + * + * @return string + */ + public function getGitPath(): string + { + return $this->options->get(self::GIT, ''); + } + + /** + * Return config data + * + * @return array + */ + public function getJsonData(): array + { + return $this->options->getAll(); + } +} diff --git a/lib/captainhook/captainhook/src/Config/Util.php b/lib/captainhook/captainhook/src/Config/Util.php new file mode 100644 index 0000000000..0553113582 --- /dev/null +++ b/lib/captainhook/captainhook/src/Config/Util.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Config; + +use CaptainHook\App\Hook\Util as HookUtil; +use CaptainHook\App\Config; +use CaptainHook\App\Storage\File\Json; +use RuntimeException; + +/** + * Class Util + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.0.3 + * @internal + */ +abstract class Util +{ + /** + * Validate a configuration + * + * @param array $json + * @return void + * @throws \RuntimeException + */ + public static function validateJsonConfiguration(array $json): void + { + self::validatePluginConfig($json); + + foreach (HookUtil::getValidHooks() as $hook => $class) { + if (isset($json[$hook])) { + self::validateHookConfig($json[$hook]); + } + } + } + + /** + * Validate a hook configuration + * + * @param array $json + * @return void + * @throws \RuntimeException + */ + public static function validateHookConfig(array $json): void + { + if (!self::keysExist(['enabled', 'actions'], $json)) { + throw new RuntimeException('Config error: invalid hook configuration'); + } + if (!is_array($json['actions'])) { + throw new RuntimeException('Config error: \'actions\' must be an array'); + } + self::validateActionsConfig($json['actions']); + } + + /** + * Validate a plugin configuration + * + * @param array $json + * @return void + * @throws \RuntimeException + */ + public static function validatePluginConfig(array $json): void + { + if (!isset($json['config']['plugins'])) { + return; + } + if (!is_array($json['config']['plugins'])) { + throw new RuntimeException('Config error: \'plugins\' must be an array'); + } + foreach ($json['config']['plugins'] as $plugin) { + if (!self::keysExist(['plugin'], $plugin)) { + throw new RuntimeException('Config error: \'plugin\' missing'); + } + if (empty($plugin['plugin'])) { + throw new RuntimeException('Config error: \'plugin\' can\'t be empty'); + } + } + } + + /** + * Validate a list of action configurations + * + * @param array $json + * @return void + * @throws \RuntimeException + */ + public static function validateActionsConfig(array $json): void + { + foreach ($json as $action) { + if (!self::keysExist(['action'], $action)) { + throw new RuntimeException('Config error: \'action\' missing'); + } + if (empty($action['action'])) { + throw new RuntimeException('Config error: \'action\' can\'t be empty'); + } + if (!empty($action['conditions'])) { + self::validateConditionsConfig($action['conditions']); + } + } + } + + /** + * Validate a list of condition configurations + * + * @param array> $json + * @throws \RuntimeException + */ + public static function validateConditionsConfig(array $json): void + { + foreach ($json as $condition) { + if (!self::keysExist(['exec'], $condition) || empty($condition['exec'])) { + throw new RuntimeException('Config error: \'exec\' is required for conditions'); + } + if (!empty($condition['args']) && !is_array($condition['args'])) { + throw new RuntimeException('Config error: invalid \'args\' configuration'); + } + } + } + + /** + * Extracts a list from a json data struct with the necessary safeguards + * + * @param array $json + * @param string $value + * @return array + */ + public static function extractListFromJson(array $json, string $value): array + { + return isset($json[$value]) && is_array($json[$value]) ? $json[$value] : []; + } + + /** + * Write the config to disk + * + * @param \CaptainHook\App\Config $config + * @return void + */ + public static function writeToDisk(Config $config): void + { + $filePath = $config->getPath(); + $file = new Json($filePath); + $file->write($config->getJsonData()); + } + + /** + * Merges a various list of settings arrays + * + * @param array $settings + * @return array + */ + public static function mergeSettings(array ...$settings): array + { + $includes = array_column($settings, Config::SETTING_INCLUDES); + $custom = array_column($settings, Config::SETTING_CUSTOM); + $mergedSettings = array_merge(...$settings); + if (!empty($includes)) { + $mergedSettings[Config::SETTING_INCLUDES] = array_merge(...$includes); + } + if (!empty($custom)) { + $mergedSettings[Config::SETTING_CUSTOM] = array_merge(...$custom); + } + + return $mergedSettings; + } + + /** + * Does an array have the expected keys + * + * @param array $keys + * @param array $subject + * @return bool + */ + private static function keysExist(array $keys, array $subject): bool + { + foreach ($keys as $key) { + if (!isset($subject[$key])) { + return false; + } + } + return true; + } +} diff --git a/lib/captainhook/captainhook/src/Console/Application.php b/lib/captainhook/captainhook/src/Console/Application.php new file mode 100644 index 0000000000..71076e369d --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Application.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console; + +use CaptainHook\App\CH; +use CaptainHook\App\Console\Command as Cmd; +use CaptainHook\App\Console\Runtime\Resolver; +use Symfony\Component\Console\Application as SymfonyApplication; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class Application + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Application extends SymfonyApplication +{ + /** + * Path to captainhook binary + * + * @var string + */ + protected string $executable; + + /** + * Cli constructor. + * + * @param string $executable + */ + public function __construct(string $executable) + { + $this->executable = $executable; + + parent::__construct('CaptainHook', CH::VERSION); + + $this->setDefaultCommand('list'); + $this->silenceXDebug(); + } + + /** + * Make sure the list command is run on default `-h|--help` executions + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \Symfony\Component\Console\Exception\ExceptionInterface + * @throws \Throwable + */ + public function doRun(InputInterface $input, OutputInterface $output): int + { + if ($this->isHelpWithoutCommand($input)) { + // Run the `list` command not `list --help` + return $this->find('list')->run($input, $output); + } + return parent::doRun($input, $output); + } + + /** + * Initializes all the CaptainHook commands + * + * @return \Symfony\Component\Console\Command\Command[] + */ + public function getDefaultCommands(): array + { + $resolver = new Resolver($this->executable); + $symfonyDefaults = parent::getDefaultCommands(); + + return array_merge( + array_slice($symfonyDefaults, 0, 2), + [ + new Cmd\Install($resolver), + new Cmd\Uninstall($resolver), + new Cmd\Configuration($resolver), + new Cmd\Info($resolver), + new Cmd\Add($resolver), + new Cmd\Disable($resolver), + new Cmd\Enable($resolver), + new Cmd\Hook\CommitMsg($resolver), + new Cmd\Hook\PostCheckout($resolver), + new Cmd\Hook\PostCommit($resolver), + new Cmd\Hook\PostMerge($resolver), + new Cmd\Hook\PostRewrite($resolver), + new Cmd\Hook\PreCommit($resolver), + new Cmd\Hook\PrepareCommitMsg($resolver), + new Cmd\Hook\PrePush($resolver), + ] + ); + } + + /** + * Append release date to version output + * + * @return string + */ + public function getLongVersion(): string + { + return sprintf( + '%s version %s %s #StandWithUkraine', + $this->getName(), + $this->getVersion(), + CH::RELEASE_DATE + ); + } + + /** + * Make sure X-Debug does not interfere with the exception handling + * + * @return void + * + * @codeCoverageIgnore + */ + private function silenceXDebug(): void + { + if (function_exists('ini_set') && extension_loaded('xdebug')) { + ini_set('xdebug.show_exception_trace', '0'); + ini_set('xdebug.scream', '0'); + } + } + + /** + * Checks if the --help is called without any sub command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @return bool + */ + private function isHelpWithoutCommand(InputInterface $input): bool + { + return $input->hasParameterOption(['--help', '-h'], true) && !$input->getFirstArgument(); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command.php b/lib/captainhook/captainhook/src/Console/Command.php new file mode 100644 index 0000000000..795e11f192 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console; + +use CaptainHook\App\Console\Runtime\Resolver; +use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; + +/** + * Class Command + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +abstract class Command extends SymfonyCommand +{ + /** + * Input output handler + * + * @var \CaptainHook\App\Console\IO|null + */ + private ?IO $io = null; + + /** + * Runtime resolver + * + * @var \CaptainHook\App\Console\Runtime\Resolver + */ + protected Resolver $resolver; + + /** + * Command constructor + * + * @param \CaptainHook\App\Console\Runtime\Resolver $resolver + */ + public function __construct(Resolver $resolver) + { + $this->resolver = $resolver; + parent::__construct(); + } + + /** + * IO setter + * + * @param \CaptainHook\App\Console\IO $io + */ + public function setIO(IO $io): void + { + $this->io = $io; + } + + /** + * IO interface getter + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return \CaptainHook\App\Console\IO + */ + public function getIO(InputInterface $input, OutputInterface $output): IO + { + if (null === $this->io) { + $this->io = new IO\DefaultIO($input, $output, $this->getHelperSet()); + } + return $this->io; + } + + /** + * Write a final error message + * + * @param \Symfony\Component\Console\Output\OutputInterface $out + * @param \Throwable $t + * @return int + * @throws \Throwable + */ + public function crash(OutputInterface $out, Throwable $t): int + { + if ($out->isDebug()) { + throw $t; + } + + $out->writeln('' . $t->getMessage() . ''); + if ($out->isVerbose()) { + $out->writeln( + 'Error triggered in file: ' . $t->getFile() . + ' in line: ' . $t->getLine() + ); + } + return 1; + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Add.php b/lib/captainhook/captainhook/src/Console/Command/Add.php new file mode 100644 index 0000000000..bc01c1c4a7 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Add.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Runner\Config\Editor; +use Exception; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class Add + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class Add extends ConfigAware +{ + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('config:add') + ->setAliases(['add']) + ->setDescription('Add an action to your hook configuration') + ->setHelp('Add an action to your hook configuration') + ->addArgument('hook', InputArgument::REQUIRED, 'Hook you want to add the action to'); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \CaptainHook\App\Exception\InvalidHookName + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $io = $this->getIO($input, $output); + $config = $this->createConfig($input, true); + + $this->determineVerbosity($output, $config); + + $editor = new Editor($io, $config); + $editor->setHook(IOUtil::argToString($input->getArgument('hook'))) + ->setChange('AddAction') + ->run(); + + return 0; + } catch (Exception $e) { + return $this->crash($output, $e); + } + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/ConfigAware.php b/lib/captainhook/captainhook/src/Console/Command/ConfigAware.php new file mode 100644 index 0000000000..507760ffe0 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/ConfigAware.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\CH; +use CaptainHook\App\Config; +use CaptainHook\App\Console\Command; +use CaptainHook\App\Console\IOUtil; +use RuntimeException; +use SebastianFeldmann\Camino\Check; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class ConfigAware + * + * Base class for all commands that need to be aware of the CaptainHook configuration. + * + * @package CaptainHook\App + */ +abstract class ConfigAware extends Command +{ + /** + * Set up the configuration command option + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->addOption( + 'configuration', + 'c', + InputOption::VALUE_OPTIONAL, + 'Path to your captainhook.json configuration', + './' . CH::CONFIG + ); + } + + /** + * Create a new Config object + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param bool $failIfNotFound + * @param array $settings + * @return \CaptainHook\App\Config + * @throws \Exception + */ + protected function createConfig(InputInterface $input, bool $failIfNotFound = false, array $settings = []): Config + { + $config = Config\Factory::create($this->getConfigPath($input), $this->fetchConfigSettings($input, $settings)); + if ($failIfNotFound && !$config->isLoadedFromFile()) { + throw new RuntimeException( + 'Please create a captainhook configuration first' . PHP_EOL . + 'Run \'captainhook configure\'' . PHP_EOL . + 'If you have a configuration located elsewhere use the --configuration option' + ); + } + return $config; + } + + /** + * Return the given config path option + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @return string + */ + private function getConfigPath(InputInterface $input): string + { + $path = IOUtil::argToString($input->getOption('configuration')); + + // if path not absolute + if (!Check::isAbsolutePath($path)) { + // try to guess the config location and + // transform relative path to absolute path + if (substr($path, 0, 2) === './') { + return getcwd() . substr($path, 1); + } + return getcwd() . '/' . $path; + } + return $path; + } + + /** + * Return list of available options to overwrite the configuration settings + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param array $settingNames + * @return array + */ + private function fetchConfigSettings(InputInterface $input, array $settingNames): array + { + $settings = []; + foreach ($settingNames as $setting) { + $value = IOUtil::argToString($input->getOption($setting)); + if (!empty($value)) { + $settings[$setting] = $value; + } + } + return $settings; + } + + /** + * Check which verbosity to use, either the cli option or the config file setting + * + * @param \Symfony\Component\Console\Output\OutputInterface $out + * @param \CaptainHook\App\Config $config + * @return void + */ + protected function determineVerbosity(OutputInterface $out, Config $config): void + { + $verbosity = IOUtil::mapConfigVerbosity($config->getVerbosity()); + $cliVerbosity = $out->getVerbosity(); + if ($cliVerbosity !== OutputInterface::VERBOSITY_NORMAL) { + $verbosity = $cliVerbosity; + } + $out->setVerbosity($verbosity); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Configuration.php b/lib/captainhook/captainhook/src/Console/Command/Configuration.php new file mode 100644 index 0000000000..8dd0867341 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Configuration.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Console\Runtime\Resolver; +use CaptainHook\App\Runner\Config\Creator; +use Exception; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class Config + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Configuration extends ConfigAware +{ + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('configure') + ->setDescription('Create or update a captainhook.json configuration') + ->setHelp('Create or update a captainhook.json configuration') + ->addOption('extend', 'e', InputOption::VALUE_NONE, 'Extend existing configuration file') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Overwrite existing configuration file') + ->addOption('advanced', 'a', InputOption::VALUE_NONE, 'More options, but more to type') + ->addOption( + 'bootstrap', + null, + InputOption::VALUE_OPTIONAL, + 'Path to composers vendor/autoload.php' + ); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $io = $this->getIO($input, $output); + $config = $this->createConfig($input, false, ['bootstrap']); + + $this->determineVerbosity($output, $config); + + $configurator = new Creator($io, $config); + $configurator->force(IOUtil::argToBool($input->getOption('force'))) + ->extend(IOUtil::argToBool($input->getOption('extend'))) + ->advanced(IOUtil::argToBool($input->getOption('advanced'))) + ->setExecutable($this->resolver->getExecutable()) + ->run(); + + return 0; + } catch (Exception $e) { + return $this->crash($output, $e); + } + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Disable.php b/lib/captainhook/captainhook/src/Console/Command/Disable.php new file mode 100644 index 0000000000..6cf38f624f --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Disable.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Runner\Config\Editor; +use Exception; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class Add + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class Disable extends ConfigAware +{ + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('config:disable') + ->setAliases(['disable']) + ->setDescription('Disable the handling for a hook in your configuration') + ->setHelp('Disable the handling for a hook in your configuration') + ->addArgument('hook', InputArgument::REQUIRED, 'Hook you want to disable'); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \CaptainHook\App\Exception\InvalidHookName + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $io = $this->getIO($input, $output); + $config = $this->createConfig($input, true); + + $this->determineVerbosity($output, $config); + + $editor = new Editor($io, $config); + $editor->setHook(IOUtil::argToString($input->getArgument('hook'))) + ->setChange('DisableHook') + ->run(); + + return 0; + } catch (Exception $e) { + return $this->crash($output, $e); + } + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Enable.php b/lib/captainhook/captainhook/src/Console/Command/Enable.php new file mode 100644 index 0000000000..6c7e63b786 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Enable.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Runner\Config\Editor; +use Exception; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class Add + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class Enable extends ConfigAware +{ + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('config:enable') + ->setAliases(['enable']) + ->setDescription('Enable the handling for a hook in your configuration') + ->setHelp('Enable the handling for a hook in your configuration') + ->addArgument('hook', InputArgument::REQUIRED, 'Hook you want to enable'); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \CaptainHook\App\Exception\InvalidHookName + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $io = $this->getIO($input, $output); + $config = $this->createConfig($input, true); + + $this->determineVerbosity($output, $config); + + $editor = new Editor($io, $config); + $editor->setHook(IOUtil::argToString($input->getArgument('hook'))) + ->setChange('EnableHook') + ->run(); + + return 0; + } catch (Exception $e) { + return $this->crash($output, $e); + } + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook.php b/lib/captainhook/captainhook/src/Console/Command/Hook.php new file mode 100644 index 0000000000..6c6f3ff827 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Config; +use CaptainHook\App\Hook\Util as HookUtil; +use CaptainHook\App\Runner\Bootstrap\Util as BootstrapUtil; +use CaptainHook\App\Runner\Util as RunnerUtil; +use RuntimeException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; + +/** + * Class Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class Hook extends RepositoryAware +{ + /** + * Name of the hook to execute + * + * @var string + */ + protected string $hookName; + + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('hook:' . $this->hookName) + ->setAliases([$this->hookName]) + ->setDescription('Run git ' . $this->hookName . ' hook') + ->setHelp('This command executes the ' . $this->hookName . ' hook'); + + $this->addOption( + 'bootstrap', + 'b', + InputOption::VALUE_OPTIONAL, + 'Relative path from your config file to your bootstrap file' + ); + $this->addOption( + 'input', + 'i', + InputOption::VALUE_OPTIONAL, + 'Original hook stdIn' + ); + $this->addOption( + 'no-plugins', + null, + InputOption::VALUE_NONE, + 'Disable all hook plugins' + ); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \Throwable + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($this->shouldHooksBeSkipped()) { + $output->writeLn('all hooks were skipped because of the environment variable CAPTAINHOOK_SKIP_HOOKS or CI'); + return 0; + } + + $io = $this->getIO($input, $output); + $config = $this->createConfig($input, true, ['git-directory', 'bootstrap']); + $repository = $this->createRepository(dirname($config->getGitDirectory())); + + // use ansi coloring if available and not disabled in captainhook.json + $output->setDecorated($output->isDecorated() && $config->useAnsiColors()); + // use the configured verbosity to manage general output verbosity + $this->determineVerbosity($output, $config); + + try { + $this->handleBootstrap($config); + + $class = '\\CaptainHook\\App\\Runner\\Hook\\' . HookUtil::getHookCommand($this->hookName); + /** @var \CaptainHook\App\Runner\Hook $hook */ + $hook = new $class($io, $config, $repository); + $hook->setPluginsDisabled($input->getOption('no-plugins')); + $hook->run(); + + return 0; + } catch (Throwable $t) { + return $this->crash($output, $t); + } + } + + /** + * If CaptainHook is executed via PHAR this handles the bootstrap file inclusion + * + * @param \CaptainHook\App\Config $config + */ + private function handleBootstrap(Config $config): void + { + // we only have to care about bootstrapping PHAR builds because for + // Composer installations the bootstrapping is already done in the bin script + if ($this->resolver->isPharRelease()) { + // check the custom and default autoloader + $bootstrapFile = BootstrapUtil::validateBootstrapPath($this->resolver->isPharRelease(), $config); + // since the phar has its own autoloader, we don't need to do anything + // if the bootstrap file is not actively set + if (empty($bootstrapFile)) { + return; + } + // the bootstrap file exists, so let's load it + try { + require $bootstrapFile; + } catch (Throwable $t) { + throw new RuntimeException( + 'Loading bootstrap file failed: ' . $bootstrapFile . PHP_EOL . + $t->getMessage() . PHP_EOL + ); + } + } + } + + /** + * Indicates if hooks should be skipped + * + * Either because of CI environment or the SKIP environment variable is set. + * + * @return bool + */ + private function shouldHooksBeSkipped(): bool + { + foreach (['CAPTAINHOOK_SKIP_HOOKS', 'CI'] as $envVar) { + $skip = (int) RunnerUtil::getEnv($envVar, "0"); + if ($skip === 1) { + return true; + } + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/CommitMsg.php b/lib/captainhook/captainhook/src/Console/Command/Hook/CommitMsg.php new file mode 100644 index 0000000000..3e5a20efbc --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/CommitMsg.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; +use Symfony\Component\Console\Input\InputArgument; + +/** + * Class CommitMessage + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class CommitMsg extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hookName = Hooks::COMMIT_MSG; + + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->addArgument(Hooks::ARG_MESSAGE_FILE, InputArgument::REQUIRED, 'File containing the commit message.'); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/PostCheckout.php b/lib/captainhook/captainhook/src/Console/Command/Hook/PostCheckout.php new file mode 100644 index 0000000000..78675300e6 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/PostCheckout.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; +use Symfony\Component\Console\Input\InputArgument; + +/** + * Class PostCheckout + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.1.0 + */ +class PostCheckout extends Hook +{ + /** + * Hook to execute. + * + * @var string + */ + protected string $hookName = Hooks::POST_CHECKOUT; + + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->addArgument(Hooks::ARG_PREVIOUS_HEAD, InputArgument::OPTIONAL, 'Previous HEAD'); + $this->addArgument(Hooks::ARG_NEW_HEAD, InputArgument::OPTIONAL, 'New HEAD'); + $this->addArgument(Hooks::ARG_MODE, InputArgument::OPTIONAL, 'Checkout mode 1 branch 0 file'); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/PostCommit.php b/lib/captainhook/captainhook/src/Console/Command/Hook/PostCommit.php new file mode 100644 index 0000000000..5e61168bc2 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/PostCommit.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; + +/** + * Class PostCommit + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class PostCommit extends Hook +{ + /** + * Hook to execute. + * + * @var string + */ + protected string $hookName = Hooks::POST_COMMIT; +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/PostMerge.php b/lib/captainhook/captainhook/src/Console/Command/Hook/PostMerge.php new file mode 100644 index 0000000000..ef3891b2da --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/PostMerge.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; +use Symfony\Component\Console\Input\InputArgument; + +/** + * Class PostMerge + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.0.1 + */ +class PostMerge extends Hook +{ + /** + * Hook to execute. + * + * @var string + */ + protected string $hookName = Hooks::POST_MERGE; + + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->addArgument(Hooks::ARG_SQUASH, InputArgument::OPTIONAL, 'Merge was done with a squash merge.'); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/PostRewrite.php b/lib/captainhook/captainhook/src/Console/Command/Hook/PostRewrite.php new file mode 100644 index 0000000000..b29afadc77 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/PostRewrite.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; +use Symfony\Component\Console\Input\InputArgument; + +/** + * Class PostRewrite + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.0 + */ +class PostRewrite extends Hook +{ + /** + * Hook to execute. + * + * @var string + */ + protected string $hookName = Hooks::POST_REWRITE; + + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->addArgument(Hooks::ARG_GIT_COMMAND, InputArgument::OPTIONAL, 'Executed command'); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/PreCommit.php b/lib/captainhook/captainhook/src/Console/Command/Hook/PreCommit.php new file mode 100644 index 0000000000..af1ea5c813 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/PreCommit.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; + +/** + * Class PreCommit + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class PreCommit extends Hook +{ + /** + * Hook to execute. + * + * @var string + */ + protected string $hookName = Hooks::PRE_COMMIT; +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/PrePush.php b/lib/captainhook/captainhook/src/Console/Command/Hook/PrePush.php new file mode 100644 index 0000000000..1170ba4404 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/PrePush.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; +use Symfony\Component\Console\Input\InputArgument; + +/** + * Class PrePush + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class PrePush extends Hook +{ + /** + * Hook to execute. + * + * @var string + */ + protected string $hookName = Hooks::PRE_PUSH; + + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->addArgument(Hooks::ARG_TARGET, InputArgument::OPTIONAL, 'Target repository name'); + $this->addArgument(Hooks::ARG_URL, InputArgument::OPTIONAL, 'Target repository url'); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Hook/PrepareCommitMsg.php b/lib/captainhook/captainhook/src/Console/Command/Hook/PrepareCommitMsg.php new file mode 100644 index 0000000000..28464eb30b --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Hook/PrepareCommitMsg.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command\Hook; + +use CaptainHook\App\Console\Command\Hook; +use CaptainHook\App\Hooks; +use Symfony\Component\Console\Input\InputArgument; + +/** + * Class PrepareCommitMessage + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class PrepareCommitMsg extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hookName = Hooks::PREPARE_COMMIT_MSG; + + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->addArgument(Hooks::ARG_MESSAGE_FILE, InputArgument::REQUIRED, 'File containing the commit log message'); + $this->addArgument(Hooks::ARG_MODE, InputArgument::OPTIONAL, 'Current commit mode'); + $this->addArgument(Hooks::ARG_HASH, InputArgument::OPTIONAL, 'Given commit hash'); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Info.php b/lib/captainhook/captainhook/src/Console/Command/Info.php new file mode 100644 index 0000000000..b147c1c8ab --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Info.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Runner\Config\Reader; +use Exception; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Command to display configuration information + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.24.0 + */ +class Info extends RepositoryAware +{ + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('config:info') + ->setAliases(['info']) + ->setDescription('Displays information about the configuration') + ->setHelp('Displays information about the configuration') + ->addArgument('hook', InputArgument::OPTIONAL, 'Hook you want to investigate') + ->addOption( + 'list-actions', + 'a', + InputOption::VALUE_NONE, + 'List all actions' + ) + ->addOption( + 'list-conditions', + 'p', + InputOption::VALUE_NONE, + 'List all conditions' + ) + ->addOption( + 'list-options', + 'o', + InputOption::VALUE_NONE, + 'List all options' + ) + ->addOption( + 'list-config', + 's', + InputOption::VALUE_NONE, + 'List all config settings' + ) + ->addOption( + 'extensive', + 'e', + InputOption::VALUE_NONE, + 'Show more detailed information' + ); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \CaptainHook\App\Exception\InvalidHookName + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $io = $this->getIO($input, $output); + $config = $this->createConfig($input, true, ['git-directory']); + $repo = $this->createRepository(dirname($config->getGitDirectory())); + + $this->determineVerbosity($output, $config); + + $editor = new Reader($io, $config, $repo); + $editor->setHook(IOUtil::argToString($input->getArgument('hook'))) + ->display(Reader::OPT_ACTIONS, $input->getOption('list-actions')) + ->display(Reader::OPT_CONDITIONS, $input->getOption('list-conditions')) + ->display(Reader::OPT_OPTIONS, $input->getOption('list-options')) + ->extensive($input->getOption('extensive')) + ->run(); + + return 0; + } catch (Exception $e) { + return $this->crash($output, $e); + } + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Install.php b/lib/captainhook/captainhook/src/Console/Command/Install.php new file mode 100644 index 0000000000..5bce00c61e --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Install.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Hook\Template; +use CaptainHook\App\Runner\Installer; +use Exception; +use RuntimeException; +use SebastianFeldmann\Git\Repository; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class Install + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Install extends RepositoryAware +{ + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('install') + ->setDescription('Install hooks to your .git/hooks directory') + ->setHelp('Install git hooks to your .git/hooks directory') + ->addArgument( + 'hook', + InputArgument::OPTIONAL, + 'Limit the hooks you want to install. ' . + 'You can specify multiple hooks with comma as delimiter. ' . + 'By default all hooks get installed' + ) + ->addOption( + 'only-enabled', + null, + InputOption::VALUE_NONE, + 'Limit the hooks you want to install to those enabled in your conf. ' . + 'By default all hooks get installed' + ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force install without confirmation' + ) + ->addOption( + 'skip-existing', + 's', + InputOption::VALUE_NONE, + 'Do not overwrite existing hooks' + ) + ->addOption( + 'move-existing-to', + null, + InputOption::VALUE_OPTIONAL, + 'Move existing hooks to given directory' + ) + ->addOption( + 'bootstrap', + 'b', + InputOption::VALUE_OPTIONAL, + 'Path to composers vendor/autoload.php' + ) + ->addOption( + 'run-mode', + 'm', + InputOption::VALUE_OPTIONAL, + 'Git hook run mode [php|shell|docker]' + ) + ->addOption( + 'run-exec', + 'e', + InputOption::VALUE_OPTIONAL, + 'The Docker command to start your container e.g. \'docker exec CONTAINER\'' + ) + ->addOption( + 'run-path', + 'p', + InputOption::VALUE_OPTIONAL, + 'The path to the CaptainHook executable \'/usr/bin/captainhook\'' + ); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $args = ['git-directory', 'run-mode', 'run-exec', 'run-path', 'bootstrap']; + $io = $this->getIO($input, $output); + $config = $this->createConfig($input, true, $args); + $repo = $this->createRepository(dirname($config->getGitDirectory())); + $template = $this->createTemplate($config, $repo); + + $this->determineVerbosity($output, $config); + + $installer = new Installer($io, $config, $repo, $template); + $installer->setHook(IOUtil::argToString($input->getArgument('hook'))) + ->setForce(IOUtil::argToBool($input->getOption('force'))) + ->setSkipExisting(IOUtil::argToBool($input->getOption('skip-existing'))) + ->setMoveExistingTo(IOUtil::argToString($input->getOption('move-existing-to'))) + ->setOnlyEnabled(IOUtil::argToBool($input->getOption('only-enabled'))) + ->run(); + + return 0; + } catch (Exception $e) { + return $this->crash($output, $e); + } + } + + /** + * Create the template to generate the hook source code + * + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repo + * @return \CaptainHook\App\Hook\Template + */ + private function createTemplate(Config $config, Repository $repo): Template + { + if ( + $config->getRunConfig()->getMode() === Template::DOCKER + && empty($config->getRunConfig()->getDockerCommand()) + ) { + throw new RuntimeException('Run "exec" option missing for run-mode docker.'); + } + + return Template\Builder::build($config, $repo, $this->resolver); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/RepositoryAware.php b/lib/captainhook/captainhook/src/Console/Command/RepositoryAware.php new file mode 100644 index 0000000000..af11cf740b --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/RepositoryAware.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Console\Runtime\Resolver; +use SebastianFeldmann\Git\Repository; +use Symfony\Component\Console\Input\InputOption; + +/** + * Trait RepositoryAware + * + * Trait for all commands that needs to be aware of the git repository. + * + * @package CaptainHook\App\Console\Command + */ +class RepositoryAware extends ConfigAware +{ + /** + * Configure method to set up the git-directory command option + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + + $this->addOption( + 'git-directory', + 'g', + InputOption::VALUE_OPTIONAL, + 'Path to your .git directory' + ); + } + + /** + * Create a new git repository representation + * + * @param string $path + * @return \SebastianFeldmann\Git\Repository + */ + protected function createRepository(string $path): Repository + { + return Repository::createVerified($path); + } +} diff --git a/lib/captainhook/captainhook/src/Console/Command/Uninstall.php b/lib/captainhook/captainhook/src/Console/Command/Uninstall.php new file mode 100644 index 0000000000..b6a421ad07 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Command/Uninstall.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Command; + +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Runner\Uninstaller; +use Exception; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class Uninstall + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.17.0 + */ +class Uninstall extends RepositoryAware +{ + /** + * Configure the command + * + * @return void + */ + protected function configure(): void + { + parent::configure(); + $this->setName('uninstall') + ->setDescription('Remove all git hooks from your .git/hooks directory') + ->setHelp('Remove all git hooks from your .git/hooks directory') + ->addArgument( + 'hook', + InputArgument::OPTIONAL, + 'Remove only this one hook. By default all hooks get uninstalled' + ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force install without confirmation' + ) + ->addOption( + 'only-disabled', + null, + InputOption::VALUE_NONE, + 'Limit the hooks you want to remove to those that are not enabled in your conf. ' . + 'By default all hooks get uninstalled' + ) + ->addOption( + 'move-existing-to', + null, + InputOption::VALUE_OPTIONAL, + 'Move existing hooks to this directory' + ); + } + + /** + * Execute the command + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = $this->getIO($input, $output); + + try { + $config = $this->createConfig($input, true, ['git-directory']); + $repo = $this->createRepository(dirname($config->getGitDirectory())); + + $this->determineVerbosity($output, $config); + + $uninstaller = new Uninstaller($io, $config, $repo); + $uninstaller->setHook(IOUtil::argToString($input->getArgument('hook'))) + ->setForce(IOUtil::argToBool($input->getOption('force'))) + ->setOnlyDisabled(IOUtil::argToBool($input->getOption('only-disabled'))) + ->setMoveExistingTo(IOUtil::argToString($input->getOption('move-existing-to'))) + ->run(); + + return 0; + } catch (Exception $e) { + $this->crash($output, $e); + return 1; + } + } +} diff --git a/lib/captainhook/captainhook/src/Console/IO.php b/lib/captainhook/captainhook/src/Console/IO.php new file mode 100644 index 0000000000..7ce7b13782 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IO.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console; + +/** + * Interface IO + * + * @package CaptainHook + * @author Nils Adermann + * @author Jordi Boggiano + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +interface IO +{ + public const QUIET = 1; + public const NORMAL = 2; + public const VERBOSE = 4; + public const VERY_VERBOSE = 8; + public const DEBUG = 16; + + /** + * Return the original cli arguments + * + * @return array + */ + public function getArguments(): array; + + /** + * Return the original cli argument or a given default + * + * @param string $name + * @param string $default + * @return string + */ + public function getArgument(string $name, string $default = ''): string; + + /** + * Returns the piped in standard input + * + * @return string[] + */ + public function getStandardInput(): array; + + /** + * Is this input interactive? + * + * @return bool + */ + public function isInteractive(); + + /** + * Is this output verbose? + * + * @return bool + */ + public function isVerbose(); + + /** + * Is the output very verbose? + * + * @return bool + */ + public function isVeryVerbose(); + + /** + * Is the output in debug verbosity? + * + * @return bool + */ + public function isDebug(); + + /** + * Writes a message to the output + * + * @param string|array $messages The message as an array of lines or a single string + * @param bool $newline Whether to add a newline or not + * @param int $verbosity Verbosity level from the VERBOSITY_* constants + * @return void + */ + public function write($messages, $newline = true, $verbosity = self::NORMAL); + + /** + * Writes a message to the error output + * + * @param string|array $messages The message as an array of lines or a single string + * @param bool $newline Whether to add a newline or not + * @param int $verbosity Verbosity level from the VERBOSITY_* constants + * @return void + */ + public function writeError($messages, $newline = true, $verbosity = self::NORMAL); + + /** + * Asks a question to the user + * + * @param string $question The question to ask + * @param string $default The default answer if none is given by the user + * @throws \RuntimeException If there is no data to read in the input stream + * @return string The user answer + */ + public function ask($question, $default = null); + + /** + * Asks a confirmation to the user + * + * The question will be asked until the user answers by nothing, yes, or no. + * + * @param string $question The question to ask + * @param bool $default The default answer if the user enters nothing + * @return bool true if the user has confirmed, false otherwise + */ + public function askConfirmation($question, $default = true); + + /** + * Asks for a value and validates the response + * + * The validator receives the data to validate. It must return the + * validated data when the data is valid and throw an exception + * otherwise. + * + * @param string $question The question to ask + * @param callable $validator A PHP callback + * @param int $attempts Max number of times to ask before giving up (default of null means infinite) + * @param mixed $default The default answer if none is given by the user + * @throws \Exception When any of the validators return an error + * @return mixed + */ + public function askAndValidate($question, $validator, $attempts = null, $default = null); +} diff --git a/lib/captainhook/captainhook/src/Console/IO/Base.php b/lib/captainhook/captainhook/src/Console/IO/Base.php new file mode 100644 index 0000000000..75b74149ed --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IO/Base.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\IO; + +use CaptainHook\App\Console\IO; + +/** + * Class Base + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class Base implements IO +{ + /** + * Return the original cli arguments + * + * @return array + */ + public function getArguments(): array + { + return []; + } + + /** + * Return the original cli argument or a given default + * + * @param string $name + * @param string $default + * @return string + */ + public function getArgument(string $name, string $default = ''): string + { + return $default; + } + + /** + * Return the piped in standard input + * + * @return string[] + */ + public function getStandardInput(): array + { + return []; + } +} diff --git a/lib/captainhook/captainhook/src/Console/IO/CollectorIO.php b/lib/captainhook/captainhook/src/Console/IO/CollectorIO.php new file mode 100644 index 0000000000..2dfec2b31d --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IO/CollectorIO.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\IO; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use SebastianFeldmann\Cli\Reader\StandardInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; + +/** + * Class CollectorIO + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.19.0 + */ +class CollectorIO implements IO +{ + /** + * @var \CaptainHook\App\Console\IO + */ + private IO $io; + + /** + * @var array<\CaptainHook\App\Console\IO\Message> + */ + private array $messages = []; + + /** + * Constructor + * + */ + public function __construct(IO $io) + { + $this->io = $io; + } + + + /** + * + * @param string $messages + * @param bool $newline + * @param int $verbosity + * @return void + */ + public function write($messages, $newline = true, $verbosity = IO::NORMAL) + { + $this->messages[] = new Message($messages, $newline, $verbosity); + } + + /** + * @param string $messages + * @param bool $newline + * @param int $verbosity + * @return void + */ + public function writeError($messages, $newline = true, $verbosity = IO::NORMAL) + { + $this->messages[] = new Message($messages, $newline, $verbosity); + } + + /** + * @return array<\CaptainHook\App\Console\IO\Message> + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Return the original cli arguments + * + * @return array + */ + public function getArguments(): array + { + return $this->io->getArguments(); + } + + /** + * Return the original cli argument or a given default + * + * @param string $name + * @param string $default + * @return string + */ + public function getArgument(string $name, string $default = ''): string + { + return $this->io->getArgument($name, $default); + } + + /** + * Return the piped in standard input + * + * @return string[] + */ + public function getStandardInput(): array + { + return $this->io->getStandardInput(); + } + + public function isInteractive() + { + return $this->io->isInteractive(); + } + + public function isVerbose() + { + return $this->io->isVerbose(); + } + + public function isVeryVerbose() + { + return $this->io->isVeryVerbose(); + } + + public function isDebug() + { + return $this->io->isDebug(); + } + + public function ask($question, $default = null) + { + return $this->io->ask($question, $default); + } + + public function askConfirmation($question, $default = true) + { + return $this->io->askConfirmation($question, $default); + } + + public function askAndValidate($question, $validator, $attempts = null, $default = null) + { + return $this->io->askAndValidate($question, $validator, $attempts, $default); + } +} diff --git a/lib/captainhook/captainhook/src/Console/IO/ComposerIO.php b/lib/captainhook/captainhook/src/Console/IO/ComposerIO.php new file mode 100644 index 0000000000..1fa8162bc6 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IO/ComposerIO.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\IO; + +use Composer\IO\IOInterface; + +/** + * Class ComposerIO + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class ComposerIO extends Base +{ + /** + * @var \Composer\IO\IOInterface + */ + private $io; + + /** + * ComposerIO constructor + * + * @param \Composer\IO\IOInterface $io + */ + public function __construct(IOInterface $io) + { + $this->io = $io; + } + + /** + * {@inheritDoc} + */ + public function isInteractive() + { + return $this->io->isInteractive(); + } + + /** + * {@inheritDoc} + */ + public function isVerbose() + { + return $this->io->isVerbose(); + } + + /** + * {@inheritDoc} + */ + public function isVeryVerbose() + { + return $this->io->isVeryVerbose(); + } + + /** + * {@inheritDoc} + */ + public function isDebug() + { + return $this->io->isDebug(); + } + + /** + * {@inheritDoc} + */ + public function write($messages, $newline = true, $verbosity = self::NORMAL) + { + $this->io->write($messages, $newline, $verbosity); + } + + /** + * {@inheritDoc} + */ + public function writeError($messages, $newline = true, $verbosity = self::NORMAL) + { + $this->io->writeError($messages, $newline, $verbosity); + } + + /** + * {@inheritDoc} + */ + public function ask($question, $default = null) + { + return $this->io->ask($question, $default); + } + + /** + * {@inheritDoc} + */ + public function askConfirmation($question, $default = true) + { + return $this->io->askConfirmation($question, $default); + } + + /** + * {@inheritDoc} + */ + public function askAndValidate($question, $validator, $attempts = null, $default = null) + { + return $this->io->askAndValidate($question, $validator, $attempts, $default); + } +} diff --git a/lib/captainhook/captainhook/src/Console/IO/DefaultIO.php b/lib/captainhook/captainhook/src/Console/IO/DefaultIO.php new file mode 100644 index 0000000000..7fc3c44775 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IO/DefaultIO.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\IO; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use RuntimeException; +use SebastianFeldmann\Cli\Reader\StandardInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; + +/** + * Class DefaultIO + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class DefaultIO extends Base +{ + /** + * Contents of the STDIN + * + * @var array + */ + private array $stdIn = []; + + /** + * @var \Symfony\Component\Console\Input\InputInterface + */ + protected InputInterface $input; + + /** + * @var \Symfony\Component\Console\Output\OutputInterface + */ + protected OutputInterface $output; + + /** + * @var \Symfony\Component\Console\Helper\HelperSet|null + */ + protected ?HelperSet $helperSet; + + /** + * @var array + */ + private array $verbosityMap; + + /** + * Constructor + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param \Symfony\Component\Console\Helper\HelperSet|null $helperSet + */ + public function __construct(InputInterface $input, OutputInterface $output, ?HelperSet $helperSet = null) + { + $this->input = $input; + $this->output = $output; + $this->helperSet = $helperSet; + $this->verbosityMap = [ + IO::QUIET => OutputInterface::VERBOSITY_QUIET, + IO::NORMAL => OutputInterface::VERBOSITY_NORMAL, + IO::VERBOSE => OutputInterface::VERBOSITY_VERBOSE, + IO::VERY_VERBOSE => OutputInterface::VERBOSITY_VERY_VERBOSE, + IO::DEBUG => OutputInterface::VERBOSITY_DEBUG + ]; + } + + /** + * Return the original cli arguments + * + * @return array + */ + public function getArguments(): array + { + return $this->input->getArguments(); + } + + /** + * Return the original cli argument or a given default + * + * @param string $name + * @param string $default + * @return string + */ + public function getArgument(string $name, string $default = ''): string + { + return (string)($this->getArguments()[$name] ?? $default); + } + + /** + * Return the piped in standard input + * + * @return string[] + */ + public function getStandardInput(): array + { + if (empty($this->stdIn)) { + $this->stdIn = explode(PHP_EOL, trim($this->input->getOption('input'), '"')); + } + return $this->stdIn; + } + + /** + * {@inheritDoc} + */ + public function isInteractive() + { + return $this->input->isInteractive(); + } + + /** + * {@inheritDoc} + */ + public function isVerbose() + { + return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE; + } + + /** + * {@inheritDoc} + */ + public function isVeryVerbose() + { + return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE; + } + + /** + * {@inheritDoc} + */ + public function isDebug() + { + return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG; + } + + /** + * {@inheritDoc} + */ + public function write($messages, $newline = true, $verbosity = self::NORMAL) + { + $this->doWrite($messages, $newline, false, $verbosity); + } + + /** + * {@inheritDoc} + */ + public function writeError($messages, $newline = true, $verbosity = self::NORMAL) + { + $this->doWrite($messages, $newline, true, $verbosity); + } + + /** + * Write to the appropriate user output + * + * @param array|string $messages + * @param bool $newline + * @param bool $stderr + * @param int $verbosity + * @return void + */ + private function doWrite($messages, $newline, $stderr, $verbosity) + { + $sfVerbosity = $this->verbosityMap[$verbosity]; + if ($sfVerbosity > $this->output->getVerbosity()) { + return; + } + + $this->getOutputToWriteTo($stderr)->write($messages, $newline, $sfVerbosity); + } + + /** + * {@inheritDoc} + */ + public function ask($question, $default = null) + { + if ($this->helperSet === null) { + throw new RuntimeException('You must set the helperSet before asking'); + } + /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ + $helper = $this->helperSet->get('question'); + $question = new Question($question, $default); + + return $helper->ask($this->input, $this->getOutputToWriteTo(), $question); + } + + /** + * {@inheritDoc} + */ + public function askConfirmation($question, $default = true) + { + if ($this->helperSet === null) { + throw new RuntimeException('You must set the helperSet before asking'); + } + /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ + $helper = $this->helperSet->get('question'); + $question = new ConfirmationQuestion($question, $default); + + return IOUtil::answerToBool($helper->ask($this->input, $this->getOutputToWriteTo(), $question)); + } + + /** + * {@inheritDoc} + */ + public function askAndValidate($question, $validator, $attempts = null, $default = null) + { + if ($this->helperSet === null) { + throw new RuntimeException('You must set the helperSet before asking'); + } + /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ + $helper = $this->helperSet->get('question'); + $question = new Question($question, $default); + $question->setValidator($validator); + $question->setMaxAttempts($attempts); + + return $helper->ask($this->input, $this->getOutputToWriteTo(), $question); + } + + /** + * Return the output to write to + * + * @param bool $stdErr + * @return \Symfony\Component\Console\Output\OutputInterface + */ + private function getOutputToWriteTo($stdErr = false) + { + if ($stdErr && $this->output instanceof ConsoleOutputInterface) { + return $this->output->getErrorOutput(); + } + + return $this->output; + } +} diff --git a/lib/captainhook/captainhook/src/Console/IO/Message.php b/lib/captainhook/captainhook/src/Console/IO/Message.php new file mode 100644 index 0000000000..8bc5ff0470 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IO/Message.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\IO; + +/** + * Class Message + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.19.0 + */ +class Message +{ + /** + * Message, either a string or list of string for multiple lines + * + * @var string|array + */ + private string|array $message; + + /** + * Should message be ended with a new line character + * + * @var bool + */ + private bool $newLine; + + /** + * Current application verbosity + * + * @var int + */ + private int $verbosity; + + /** + * Constructor + * + * @param string|array $message + * @param bool $newLine + * @param int $verbosity + */ + public function __construct(string|array $message, bool $newLine, int $verbosity) + { + $this->message = $message; + $this->newLine = $newLine; + $this->verbosity = $verbosity; + } + + /** + * Returns the message to print + * + * @return string|array + */ + public function message(): string|array + { + return $this->message; + } + + /** + * If true message should end with a new line + * + * @return bool + */ + public function newLine(): bool + { + return $this->newLine; + } + + /** + * Minimum verbosity this message should be displayed at + * + * @return int + */ + public function verbosity(): int + { + return $this->verbosity; + } +} diff --git a/lib/captainhook/captainhook/src/Console/IO/NullIO.php b/lib/captainhook/captainhook/src/Console/IO/NullIO.php new file mode 100644 index 0000000000..70a70087e8 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IO/NullIO.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\IO; + +/** + * Class NullIO + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class NullIO extends Base +{ + /** + * {@inheritDoc} + */ + public function isInteractive() + { + return false; + } + + /** + * {@inheritDoc} + */ + public function isVerbose() + { + return false; + } + + /** + * {@inheritDoc} + */ + public function isVeryVerbose() + { + return false; + } + + /** + * {@inheritDoc} + */ + public function isDebug() + { + return false; + } + + /** + * {@inheritDoc} + */ + public function write($messages, $newline = true, $verbosity = self::NORMAL) + { + } + + /** + * {@inheritDoc} + */ + public function writeError($messages, $newline = true, $verbosity = self::NORMAL) + { + } + + /** + * {@inheritDoc} + */ + public function ask($question, $default = null) + { + return (string) $default; + } + + /** + * {@inheritDoc} + */ + public function askConfirmation($question, $default = true) + { + return $default; + } + + /** + * {@inheritDoc} + */ + public function askAndValidate($question, $validator, $attempts = null, $default = null) + { + return $default; + } +} diff --git a/lib/captainhook/captainhook/src/Console/IOUtil.php b/lib/captainhook/captainhook/src/Console/IOUtil.php new file mode 100644 index 0000000000..324b1e6e82 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/IOUtil.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * IOUtil class + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class IOUtil +{ + public const PREFIX_OK = ''; + public const PREFIX_FAIL = '✘'; + + /** + * Maps config values to Symfony verbosity values + * + * @var array + */ + private static array $verbosityMap = [ + 'quiet' => OutputInterface::VERBOSITY_QUIET, + 'normal' => OutputInterface::VERBOSITY_NORMAL, + 'verbose' => OutputInterface::VERBOSITY_VERBOSE, + 'very-verbose' => OutputInterface::VERBOSITY_VERY_VERBOSE, + 'debug' => OutputInterface::VERBOSITY_DEBUG + ]; + + /** + * Return the Symfony verbosity for a given config value + * + * @param string $verbosity + * @return OutputInterface::VERBOSITY_* + */ + public static function mapConfigVerbosity(string $verbosity): int + { + return self::$verbosityMap[strtolower($verbosity)] ?? OutputInterface::VERBOSITY_NORMAL; + } + + /** + * Convert a user answer to boolean + * + * @param string $answer + * @return bool + */ + public static function answerToBool(string $answer): bool + { + return in_array(strtolower($answer), ['y', 'yes', 'ok']); + } + + /** + * Create formatted cli headline + * + * ">>>> HEADLINE <<<<" + * "==== HEADLINE ====" + * + * @param string $headline + * @param int $length + * @param string $pre + * @param string $post + * @return string + */ + public static function formatHeadline(string $headline, int $length, string $pre = '=', string $post = '='): string + { + $headlineLength = mb_strlen($headline); + if ($headlineLength > ($length - 3)) { + return $headline; + } + + $prefix = (int) floor(($length - $headlineLength - 2) / 2); + $suffix = (int) ceil(($length - $headlineLength - 2) / 2); + + return str_repeat($pre, $prefix) . ' ' . $headline . ' ' . str_repeat($post, $suffix); + } + + /** + * Convert everything to a string + * + * @param array|bool|string|null $arg + * @param string $default + * @return string + */ + public static function argToString(mixed $arg, string $default = ''): string + { + return is_string($arg) ? $arg : $default; + } + + /** + * Convert everything to a boolean + * + * @param array|bool|string|null $arg + * @param bool $default + * @return bool + */ + public static function argToBool(mixed $arg, bool $default = false): bool + { + return is_bool($arg) ? $arg : $default; + } +} diff --git a/lib/captainhook/captainhook/src/Console/Runtime/Resolver.php b/lib/captainhook/captainhook/src/Console/Runtime/Resolver.php new file mode 100644 index 0000000000..d264746f91 --- /dev/null +++ b/lib/captainhook/captainhook/src/Console/Runtime/Resolver.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Console\Runtime; + +/** + * Class Resolver + * + * @package CaptainHook\App + */ +class Resolver +{ + /** + * PHAR flag, replaced by box during PHAR building + * + * @var string + */ + private string $runtime = '@runtime@'; + + /** + * Path to the currently executed 'binary' + * + * @var string + */ + private string $executable; + + /** + * Resolver constructor. + * + * @param string $executable + */ + public function __construct(string $executable = 'bin/vendor/captainhook') + { + $this->executable = $executable; + } + + /** + * Return current executed 'binary' + * + * @return string + */ + public function getExecutable(): string + { + return $this->executable; + } + + /** + * Check if the current runtime is executed via PHAR + * + * @return bool + */ + public function isPharRelease(): bool + { + return $this->runtime === 'PHAR'; + } +} diff --git a/lib/captainhook/captainhook/src/Event.php b/lib/captainhook/captainhook/src/Event.php new file mode 100644 index 0000000000..e41e4b2122 --- /dev/null +++ b/lib/captainhook/captainhook/src/Event.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App; + +use CaptainHook\App\Console\IO; +use SebastianFeldmann\Git\Repository; + +/** + * Event interface + * + * Allows event handlers to do output access the app setup or the repository. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +interface Event +{ + /** + * Returns the event trigger name + * + * @return string + */ + public function name(): string; + + /** + * Returns the captainhook config, most likely needed to access any original command line arguments + * + * @return \CaptainHook\App\Config + */ + public function config(): Config; + + /** + * Returns IO to do some output + * + * @return \CaptainHook\App\Console\IO + */ + public function io(): IO; + + /** + * Returns the git repository + * + * @return \SebastianFeldmann\Git\Repository + */ + public function repository(): Repository; +} diff --git a/lib/captainhook/captainhook/src/Event/Dispatcher.php b/lib/captainhook/captainhook/src/Event/Dispatcher.php new file mode 100644 index 0000000000..28568eef54 --- /dev/null +++ b/lib/captainhook/captainhook/src/Event/Dispatcher.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Event; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use SebastianFeldmann\Git\Repository; + +/** + * Event Dispatcher + * + * This allows the user to hook into the Cap'n on a deeper level. For example execute code if the hook execution fails. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class Dispatcher +{ + /** + * List of all registered handlers + * + * @var array> + */ + private $config = []; + + /** + * Event factory to create all necessary events + * + * @var \CaptainHook\App\Event\Factory + */ + private $factory; + + /** + * Event Dispatcher + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository) + { + $this->factory = new Factory($io, $config, $repository); + } + + /** + * Register handlers received from a Listener to the dispatcher + * + * @param array> $eventConfig + * @return void + */ + public function subscribeHandlers(array $eventConfig): void + { + foreach ($eventConfig as $event => $handlers) { + foreach ($handlers as $handler) { + $this->subscribeHandlerToEvent($handler, $event); + } + } + } + + /** + * Register a single event handler to an event + * + * @param \CaptainHook\App\Event\Handler $handler + * @param string $event + * @return void + */ + public function subscribeHandlerToEvent(Handler $handler, string $event): void + { + $this->config[$event][] = $handler; + } + + /** + * Trigger all event handlers registered for a given event + * + * @param string $eventName + * @throws \Exception + * @return void + */ + public function dispatch(string $eventName): void + { + $event = $this->factory->createEvent($eventName); + + foreach ($this->handlersFor($event->name()) as $handler) { + $handler->handle($event); + } + } + + /** + * Return a list of handlers for a given event + * + * @param string $event + * @return \CaptainHook\App\Event\Handler[]; + */ + private function handlersFor(string $event): array + { + return $this->config[$event] ?? []; + } +} diff --git a/lib/captainhook/captainhook/src/Event/Factory.php b/lib/captainhook/captainhook/src/Event/Factory.php new file mode 100644 index 0000000000..3f796aabf0 --- /dev/null +++ b/lib/captainhook/captainhook/src/Event/Factory.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Event; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Event; +use RuntimeException; +use SebastianFeldmann\Git\Repository; + +/** + * Event Factory + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class Factory +{ + /** + * @var \CaptainHook\App\Config + */ + protected $config; + + /** + * @var \CaptainHook\App\Console\IO + */ + private $io; + + /** + * @var \SebastianFeldmann\Git\Repository + */ + private $repository; + + /** + * List of available events + * + * @var string[] + */ + private $validEventIDs = [ + 'onHookFailure' => HookFailed::class, + 'onHookSuccess' => HookSucceeded::class, + ]; + + /** + * Event Factory + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository) + { + $this->config = $config; + $this->io = $io; + $this->repository = $repository; + } + + /** + * Create a CaptainHook event + * + * @param string $name + * @return \CaptainHook\App\Event + */ + public function createEvent(string $name): Event + { + if (!$this->isEventIDValid($name)) { + throw new RuntimeException('invalid event name: ' . $name); + } + + $class = $this->validEventIDs[$name]; + /** @var \CaptainHook\App\Event $event */ + $event = new $class($this->io, $this->config, $this->repository); + return $event; + } + + /** + * Validates an event name + * + * @param string $name + * @return bool + */ + public function isEventIDValid(string $name): bool + { + return array_key_exists($name, $this->validEventIDs); + } +} diff --git a/lib/captainhook/captainhook/src/Event/Handler.php b/lib/captainhook/captainhook/src/Event/Handler.php new file mode 100644 index 0000000000..e3d36d7d4c --- /dev/null +++ b/lib/captainhook/captainhook/src/Event/Handler.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Event; + +use CaptainHook\App\Event; + +/** + * Interface EventListener + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +interface Handler +{ + /** + * Executes the handler to handle the given event + * + * @param \CaptainHook\App\Event $event + * @return void + * @throws \Exception + */ + public function handle(Event $event); +} diff --git a/lib/captainhook/captainhook/src/Event/Hook.php b/lib/captainhook/captainhook/src/Event/Hook.php new file mode 100644 index 0000000000..7fdcbf8dcb --- /dev/null +++ b/lib/captainhook/captainhook/src/Event/Hook.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Event; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Event; +use SebastianFeldmann\Git\Repository; + +/** + * Basic event class + * + * Makes sure the handler has access to the output the current app setup and the repository. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +abstract class Hook implements Event +{ + /** + * @var string + */ + protected $name; + + /** + * @var \CaptainHook\App\Console\IO + */ + protected $io; + + /** + * @var \CaptainHook\App\Config + */ + protected $config; + + /** + * @var \SebastianFeldmann\Git\Repository + */ + protected $repository; + + /** + * Event + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository) + { + $this->io = $io; + $this->config = $config; + $this->repository = $repository; + } + + /** + * @return string + */ + public function name(): string + { + return $this->name; + } + + /** + * @return \CaptainHook\App\Config + */ + public function config(): Config + { + return $this->config; + } + + /** + * @return \CaptainHook\App\Console\IO + */ + public function io(): IO + { + return $this->io; + } + + /** + * @return \SebastianFeldmann\Git\Repository + */ + public function repository(): Repository + { + return $this->repository; + } +} diff --git a/lib/captainhook/captainhook/src/Event/HookFailed.php b/lib/captainhook/captainhook/src/Event/HookFailed.php new file mode 100644 index 0000000000..366d014555 --- /dev/null +++ b/lib/captainhook/captainhook/src/Event/HookFailed.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Event; + +/** + * Hook failed event + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class HookFailed extends Hook +{ + protected $name = 'onHookFailure'; +} diff --git a/lib/captainhook/captainhook/src/Event/HookSucceeded.php b/lib/captainhook/captainhook/src/Event/HookSucceeded.php new file mode 100644 index 0000000000..182a748bfe --- /dev/null +++ b/lib/captainhook/captainhook/src/Event/HookSucceeded.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Event; + +/** + * Hook succeeded event + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class HookSucceeded extends Hook +{ + protected $name = 'onHookSuccess'; +} diff --git a/lib/captainhook/captainhook/src/Exception/ActionFailed.php b/lib/captainhook/captainhook/src/Exception/ActionFailed.php new file mode 100644 index 0000000000..4273867c6f --- /dev/null +++ b/lib/captainhook/captainhook/src/Exception/ActionFailed.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Exception; + +use Exception; + +/** + * Class ActionFailed + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class ActionFailed extends Exception implements CaptainHookException +{ +} diff --git a/lib/captainhook/captainhook/src/Exception/CaptainHookException.php b/lib/captainhook/captainhook/src/Exception/CaptainHookException.php new file mode 100644 index 0000000000..ceebf6c4d5 --- /dev/null +++ b/lib/captainhook/captainhook/src/Exception/CaptainHookException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Exception; + +use Throwable; + +/** + * CaptainHookException interface + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.9.0. + */ +interface CaptainHookException extends Throwable +{ +} diff --git a/lib/captainhook/captainhook/src/Exception/InvalidHookName.php b/lib/captainhook/captainhook/src/Exception/InvalidHookName.php new file mode 100644 index 0000000000..e5b4c5b24e --- /dev/null +++ b/lib/captainhook/captainhook/src/Exception/InvalidHookName.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Exception; + +use Exception; + +/** + * Class InvalidHookName + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class InvalidHookName extends Exception implements CaptainHookException +{ +} diff --git a/lib/captainhook/captainhook/src/Exception/InvalidPlugin.php b/lib/captainhook/captainhook/src/Exception/InvalidPlugin.php new file mode 100644 index 0000000000..61ba03ca12 --- /dev/null +++ b/lib/captainhook/captainhook/src/Exception/InvalidPlugin.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Exception; + +use RuntimeException; + +/** + * Class InvalidPlugin + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.9.0 + */ +class InvalidPlugin extends RuntimeException implements CaptainHookException +{ +} diff --git a/lib/captainhook/captainhook/src/Git/ChangedFiles.php b/lib/captainhook/captainhook/src/Git/ChangedFiles.php new file mode 100644 index 0000000000..c42559c94b --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/ChangedFiles.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\ChangedFiles\Detector\Factory; +use SebastianFeldmann\Git\Repository; + +/** + * Class ChangedFiles + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.2.0 + */ +abstract class ChangedFiles +{ + /** + * Returns the list of changed files using the necessary Detector + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param array $filter + * @return array + */ + public static function getChangedFiles(IO $io, Repository $repository, array $filter): array + { + $factory = new Factory(); + $detector = $factory->getDetector($io, $repository); + + $files = $detector->getChangedFiles($filter); + self::displayFilesFound($io, $files); + return $files; + } + + /** + * Output the changed files in verbose mode + * + * @param \CaptainHook\App\Console\IO $io + * @param array $files + * @return void + */ + private static function displayFilesFound(IO $io, array $files): void + { + if ($io->isVerbose()) { + $io->write(' Changed files', true, IO::VERBOSE); + foreach ($files as $file) { + $io->write(' - ' . $file, true, IO::VERBOSE); + } + } + } +} diff --git a/lib/captainhook/captainhook/src/Git/ChangedFiles/Detecting.php b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detecting.php new file mode 100644 index 0000000000..26eeeec08d --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detecting.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\ChangedFiles; + +/** + * Detector interface + * + * Interface to detect changed files for the different hooks. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.0 + */ +interface Detecting +{ + /** + * Returns a list of changed files + * + * @param array $filter + * @return array + */ + public function getChangedFiles(array $filter = []): array; +} diff --git a/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector.php b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector.php new file mode 100644 index 0000000000..e3d62911cf --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\ChangedFiles; + +use CaptainHook\App\Console\IO; +use SebastianFeldmann\Git\Repository; + +/** + * Class Detector + * + * Base class for all ChangedFiles Detecting implementations. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.0 + */ +abstract class Detector implements Detecting +{ + /** + * Input output handling + * + * @var \CaptainHook\App\Console\IO + */ + protected IO $io; + + /** + * Git repository + * + * @var \SebastianFeldmann\Git\Repository + */ + protected Repository $repository; + + /** + * Constructor + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Repository $repository) + { + $this->io = $io; + $this->repository = $repository; + } + + /** + * Returns a list of changed files + * + * @param array $filter + * @return array + */ + abstract public function getChangedFiles(array $filter = []): array; +} diff --git a/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/Factory.php b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/Factory.php new file mode 100644 index 0000000000..4932f7f74c --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/Factory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\ChangedFiles\Detector; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\ChangedFiles\Detecting; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Factory class + * + * Responsible for finding the previous - current ranges in every scenario + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class Factory +{ + /** + * List of available range detectors + * + * @var array + */ + private static array $detectors = [ + 'hook:pre-push' => '\\CaptainHook\\App\\Git\\ChangedFiles\\Detector\\PrePush', + 'hook:post-rewrite' => '\\CaptainHook\\App\\Git\\ChangedFiles\\Detector\\PostRewrite', + ]; + + /** + * Returns a ChangedFiles Detector + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return \CaptainHook\App\Git\ChangedFiles\Detecting + */ + public function getDetector(IO $io, Repository $repository): Detecting + { + $command = $io->getArgument(Hooks::ARG_COMMAND); + + /** @var \CaptainHook\App\Git\ChangedFiles\Detecting $class */ + $class = self::$detectors[$command] ?? '\\CaptainHook\\App\\Git\\ChangedFiles\\Detector\\Fallback'; + return new $class($io, $repository); + } +} diff --git a/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/Fallback.php b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/Fallback.php new file mode 100644 index 0000000000..6405d7a4f5 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/Fallback.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\ChangedFiles\Detector; + +use CaptainHook\App\Git\ChangedFiles\Detector; +use CaptainHook\App\Hooks; + +/** + * Class Fallback + * + * This class should not be used it is just a fallback if the `pre-push` or `post-rewrite` + * variants are somehow not applicable. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.0 + */ +class Fallback extends Detector +{ + /** + * Returns the list of changed files in a best-guess kind of way + * + * @param array $filter + * @return array + */ + public function getChangedFiles(array $filter = []): array + { + return $this->repository->getDiffOperator()->getChangedFiles( + $this->io->getArgument(Hooks::ARG_PREVIOUS_HEAD, 'HEAD@{1}'), + 'HEAD', + $filter + ); + } +} diff --git a/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/PostRewrite.php b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/PostRewrite.php new file mode 100644 index 0000000000..fac02877da --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/PostRewrite.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\ChangedFiles\Detector; + +use CaptainHook\App\Git\ChangedFiles\Detector; +use CaptainHook\App\Git\Range\Detector\PostRewrite as RangeDetector; + +/** + * Class PostRewrite + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.0 + */ +class PostRewrite extends Detector +{ + /** + * Returns a list of changed files + * + * @param array $filter + * @return array + */ + public function getChangedFiles(array $filter = []): array + { + $detector = new RangeDetector(); + $ranges = $detector->getRanges($this->io); + $old = $ranges[0]->from()->id(); + $new = $ranges[0]->to()->id(); + + return $this->repository->getDiffOperator()->getChangedFiles($old, $new, $filter); + } +} diff --git a/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/PrePush.php b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/PrePush.php new file mode 100644 index 0000000000..44f10af888 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/ChangedFiles/Detector/PrePush.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\ChangedFiles\Detector; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\ChangedFiles\Detector; +use CaptainHook\App\Git\Range\Detector\PrePush as RangeDetector; +use CaptainHook\App\Git\Range\PrePush as Range; +use SebastianFeldmann\Git\Repository; + +/** + * Class PrePush + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.0 + */ +class PrePush extends Detector +{ + /** + * Reflog fallback switch + * + * @var bool + */ + private bool $reflogFallback = false; + + /** + * Activate the reflog fallback file detection + * + * @param bool $bool + * @return void + */ + public function useReflogFallback(bool $bool): void + { + $this->reflogFallback = $bool; + } + + /** + * Return list of changed files + * + * @param array $filter + * @return array + */ + public function getChangedFiles(array $filter = []): array + { + $ranges = $this->getRanges(); + if (empty($ranges)) { + return []; + } + $files = $this->collectChangedFiles($ranges, $filter); + if (count($files) > 0 || !$this->reflogFallback) { + return $files; + } + // by now we should have found something, but if the "branch: created" entry is gone from the reflog, + // try to find as many commits belonging to this branch as possible + $branch = $ranges[0]->to()->branch(); + $revisions = $this->repository->getLogOperator()->getBranchRevsFromRefLog($branch); + return $this->repository->getLogOperator()->getChangedFilesInRevisions($revisions); + } + + /** + * Create ranges from stdIn + * + * @return array<\CaptainHook\App\Git\Range\PrePush> + */ + private function getRanges(): array + { + $detector = new RangeDetector(); + return $detector->getRanges($this->io); + } + + /** + * Collect all changed files from all ranges + * + * @param array<\CaptainHook\App\Git\Range\PrePush> $ranges + * @param array $filter + * @return array + */ + private function collectChangedFiles(array $ranges, array $filter): array + { + $files = []; + foreach ($ranges as $range) { + if ($this->isKnownBranch($range)) { + $oldHash = $range->from()->id(); + $newHash = $range->to()->id(); + } else { + if (!$this->reflogFallback) { + continue; + } + // remote branch does not exist + // try to find the branch starting point with the reflog + $oldHash = $this->repository->getLogOperator()->getBranchRevFromRefLog($range->to()->branch()); + $newHash = 'HEAD'; + } + if (!empty($oldHash)) { + $files[] = $this->repository->getDiffOperator()->getChangedFiles($oldHash, $newHash, $filter); + } + } + return array_unique(array_merge(...$files)); + } + + /** + * If the remote branch is known the diff can be easily determined + * + * @param \CaptainHook\App\Git\Range\PrePush $range + * @return bool + */ + private function isKnownBranch(Range $range): bool + { + return !$range->from()->isZeroRev(); + } +} diff --git a/lib/captainhook/captainhook/src/Git/Diff/FilterUtil.php b/lib/captainhook/captainhook/src/Git/Diff/FilterUtil.php new file mode 100644 index 0000000000..6afc61f5cb --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Diff/FilterUtil.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Diff; + +abstract class FilterUtil +{ + /** + * Converts a value into a valid diff filter array + * + * @param mixed $value + * @return array + */ + public static function filterFromConfigValue($value): array + { + return self::sanitize( + is_array($value) ? $value : str_split((string) strtoupper($value === null ? '' : $value)) + ); + } + + /** + * Remove all invalid filter options + * + * @param array $data + * @return array + */ + public static function sanitize(array $data): array + { + return array_filter($data, fn($e) => in_array($e, ['A', 'C', 'D', 'M', 'R', 'T', 'U', 'X', 'B', '*'])); + } +} diff --git a/lib/captainhook/captainhook/src/Git/Range.php b/lib/captainhook/captainhook/src/Git/Range.php new file mode 100644 index 0000000000..a99a61df5b --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Range.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git; + +/** + * Range class + * + * Represents a git range with a starting ref and an end ref. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +interface Range +{ + /** + * Returns the start ref + * + * @return \CaptainHook\App\Git\Rev + */ + public function from(): Rev; + + /** + * Returns the end ref + * + * @return \CaptainHook\App\Git\Rev + */ + public function to(): Rev; +} diff --git a/lib/captainhook/captainhook/src/Git/Range/Detecting.php b/lib/captainhook/captainhook/src/Git/Range/Detecting.php new file mode 100644 index 0000000000..1454d3c5eb --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Range/Detecting.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Range; + +use CaptainHook\App\Console\IO; + +/** + * Detecting interface + * + * Interface to gathering the previous state to current state ranges. + * To handle gathering the ranges for pre-push, post-rewrite, post-checkout separately. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +interface Detecting +{ + /** + * Returns a list of ranges marking before and after points to collect the changes happening in between + * + * @param \CaptainHook\App\Console\IO $io + * @return array<\CaptainHook\App\Git\Range> + */ + public function getRanges(IO $io): array; +} diff --git a/lib/captainhook/captainhook/src/Git/Range/Detector/Fallback.php b/lib/captainhook/captainhook/src/Git/Range/Detector/Fallback.php new file mode 100644 index 0000000000..47caa61613 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Range/Detector/Fallback.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Range\Detector; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\Range; +use CaptainHook\App\Git\Range\Detecting; +use CaptainHook\App\Git\Rev; +use CaptainHook\App\Hooks; + +/** + * Fallback Detector + * + * If no detection strategy matches the fallback detector is used to find the right range. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class Fallback implements Detecting +{ + /** + * Returns the fallback range + * + * @param \CaptainHook\App\Console\IO $io + * @return \CaptainHook\App\Git\Range\Generic[] + */ + public function getRanges(IO $io): array + { + return [ + new Range\Generic( + new Rev\Generic($io->getArgument(Hooks::ARG_PREVIOUS_HEAD, 'HEAD@{1}')), + new Rev\Generic('HEAD') + ) + ]; + } +} diff --git a/lib/captainhook/captainhook/src/Git/Range/Detector/PostRewrite.php b/lib/captainhook/captainhook/src/Git/Range/Detector/PostRewrite.php new file mode 100644 index 0000000000..8324deda18 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Range/Detector/PostRewrite.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Range\Detector; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\Range\Detecting; +use CaptainHook\App\Git\Range\Generic as Range; +use CaptainHook\App\Git\Rev\Generic as Rev; + +/** + * Class to access the pre-push stdIn data + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class PostRewrite implements Detecting +{ + /** + * Returns list of refs + * + * @param \CaptainHook\App\Console\IO $io + * @return \CaptainHook\App\Git\Range[] + */ + public function getRanges(IO $io): array + { + return $this->createFromStdIn($io->getStandardInput()); + } + + /** + * Create ranges from stdIn + * + * @param array $stdIn + * @return array<\CaptainHook\App\Git\Range> + */ + private function createFromStdIn(array $stdIn): array + { + $ranges = []; + foreach ($stdIn as $line) { + if (!empty($line)) { + $parts = explode(' ', trim($line)); + $from = new Rev(!empty($parts[1]) ? $parts[1] . '^' : 'HEAD@{1}'); + $to = new Rev('HEAD'); + $ranges[] = new Range($from, $to); + } + } + return $ranges; + } +} diff --git a/lib/captainhook/captainhook/src/Git/Range/Detector/PrePush.php b/lib/captainhook/captainhook/src/Git/Range/Detector/PrePush.php new file mode 100644 index 0000000000..bbacfa5a70 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Range/Detector/PrePush.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Range\Detector; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\Range\Detecting; +use CaptainHook\App\Git\Range\PrePush as Range; +use CaptainHook\App\Git\Rev\PrePush as Rev; +use CaptainHook\App\Git\Rev\Util; + +/** + * Class to access the pre-push stdIn data + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class PrePush implements Detecting +{ + /** + * Returns list of refs + * + * @param \CaptainHook\App\Console\IO $io + * @return array<\CaptainHook\App\Git\Range\PrePush> + */ + public function getRanges(IO $io): array + { + return $this->createFromStdIn($io->getStandardInput()); + } + + /** + * Factory method + * + * @param array $stdIn + * @return array<\CaptainHook\App\Git\Range\PrePush> + */ + private function createFromStdIn(array $stdIn): array + { + $ranges = []; + foreach ($stdIn as $line) { + if (empty($line)) { + continue; + } + + [$localRef, $localHash, $remoteRef, $remoteHash] = explode(' ', trim($line)); + + $from = new Rev($remoteRef, $remoteHash, Util::extractBranchInfo($remoteRef)['branch']); + $to = new Rev($localRef, $localHash, Util::extractBranchInfo($localRef)['branch']); + $ranges[] = new Range($from, $to); + } + return $ranges; + } +} diff --git a/lib/captainhook/captainhook/src/Git/Range/Generic.php b/lib/captainhook/captainhook/src/Git/Range/Generic.php new file mode 100644 index 0000000000..7b012cadca --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Range/Generic.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Range; + +use CaptainHook\App\Git\Range; +use CaptainHook\App\Git\Rev; + +/** + * Generic range implementation + * + * Most simple range implementation + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class Generic implements Range +{ + /** + * Starting reference + * + * @var \CaptainHook\App\Git\Rev + */ + private Rev $from; + + /** + * Ending reference + * + * @var \CaptainHook\App\Git\Rev + */ + private Rev $to; + + /** + * Constructor + * + */ + public function __construct(Rev $from, Rev $to) + { + $this->from = $from; + $this->to = $to; + } + + /** + * Return the git reference + * + * @return \CaptainHook\App\Git\Rev + */ + public function from(): Rev + { + return $this->from; + } + + /** + * @return \CaptainHook\App\Git\Rev + */ + public function to(): Rev + { + return $this->to; + } +} diff --git a/lib/captainhook/captainhook/src/Git/Range/PrePush.php b/lib/captainhook/captainhook/src/Git/Range/PrePush.php new file mode 100644 index 0000000000..3ced2eb527 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Range/PrePush.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Range; + +use CaptainHook\App\Git; +use CaptainHook\App\Git\Rev\PrePush as Rev; + +/** + * Class + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class PrePush implements Git\Range +{ + /** + * @var \CaptainHook\App\Git\Rev\PrePush + */ + private Rev $from; + + /** + * @var \CaptainHook\App\Git\Rev\PrePush + */ + private Rev $to; + + /** + * Constructor + * + * @param \CaptainHook\App\Git\Rev\PrePush $from + * @param \CaptainHook\App\Git\Rev\PrePush $to + */ + public function __construct(Rev $from, Rev $to) + { + $this->from = $from; + $this->to = $to; + } + + /** + * Returns the start ref + * + * @return \CaptainHook\App\Git\Rev\PrePush + */ + public function from(): Rev + { + return $this->from; + } + + /** + * Returns the end ref + * + * @return \CaptainHook\App\Git\Rev\PrePush + */ + public function to(): Rev + { + return $this->to; + } +} diff --git a/lib/captainhook/captainhook/src/Git/Rev.php b/lib/captainhook/captainhook/src/Git/Rev.php new file mode 100644 index 0000000000..6732b22be3 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Rev.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git; + +/** + * Ref interface + * + * Git references can be used in git commands to identify positions in the git history. + * For example: HEAD, 4FD60E21, + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +interface Rev +{ + /** + * Returns the ref id that can be used in a git command + * + * This can be completely a hash, branch name, ref-log position... + * + * @return string + */ + public function id(): string; +} diff --git a/lib/captainhook/captainhook/src/Git/Rev/Generic.php b/lib/captainhook/captainhook/src/Git/Rev/Generic.php new file mode 100644 index 0000000000..4a6c56966d --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Rev/Generic.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Rev; + +use CaptainHook\App\Git\Rev; + +/** + * Generic range implementation + * + * The simplest imaginable range implementation without any extra information. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class Generic implements Rev +{ + /** + * Referencing a git state + * + * @var string + */ + private string $id; + + /** + * Constructor + * + * @param string $id + */ + public function __construct(string $id) + { + $this->id = $id; + } + + /** + * Return the git reference + * + * @return string + */ + public function id(): string + { + return $this->id; + } +} diff --git a/lib/captainhook/captainhook/src/Git/Rev/PrePush.php b/lib/captainhook/captainhook/src/Git/Rev/PrePush.php new file mode 100644 index 0000000000..8be6c47b55 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Rev/PrePush.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Rev; + +use CaptainHook\App\Git\Rev; + +/** + * Git pre-push reference + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +class PrePush implements Rev +{ + /** + * Head path - refs/heads/main + * + * @var string + */ + private string $head; + + /** + * Git hash + * + * @var string + */ + private string $hash; + + /** + * Branch name + * + * @var string + */ + private string $branch; + + /** + * Constructor + * + * @param string $head + * @param string $hash + * @param string $branch + */ + public function __construct(string $head, string $hash, string $branch) + { + $this->head = $head; + $this->hash = $hash; + $this->branch = $branch; + } + + /** + * Head getter + * + * @return string + */ + public function head(): string + { + return $this->head; + } + + /** + * Hash getter + * + * @return string + */ + public function hash(): string + { + return $this->hash; + } + + /** + * Branch getter + * + * @return string + */ + public function branch(): string + { + return $this->branch; + } + + /** + * Returns the ref id that can be used in a git command + * + * This can be completely different thing hash, branch name, ref-log position... + * + * @return string + */ + public function id(): string + { + return $this->hash; + } + + /** + * Is this a git dummy hash + * + * @return bool + */ + public function isZeroRev(): bool + { + return Util::isZeroHash($this->hash); + } +} diff --git a/lib/captainhook/captainhook/src/Git/Rev/Util.php b/lib/captainhook/captainhook/src/Git/Rev/Util.php new file mode 100644 index 0000000000..41bf754a46 --- /dev/null +++ b/lib/captainhook/captainhook/src/Git/Rev/Util.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Git\Rev; + +/** + * Util class + * + * Does some simple format and validation stuff + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.0 + */ +abstract class Util +{ + /** + * Indicates if commit hash is a zero hash 0000000000000000000000000000000000000000 + * + * @param string $hash + * @return bool + */ + public static function isZeroHash(string $hash): bool + { + return (bool) preg_match('/^0+$/', $hash); + } + + /** + * Splits remote and branch + * + * origin/main => ['remote' => 'origin', 'branch' => 'main'] + * main => ['remote' => 'origin', 'branch' => 'main'] + * ref/origin/main => ['remote' => 'origin', 'branch' => 'main'] + * + * @param string $ref + * @return array + */ + public static function extractBranchInfo(string $ref): array + { + $ref = (string) preg_replace('#^refs/#', '', $ref); + $parts = explode('/', $ref); + + return [ + 'remote' => count($parts) > 1 ? array_shift($parts) : 'origin', + 'branch' => implode('/', $parts), + ]; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Action.php b/lib/captainhook/captainhook/src/Hook/Action.php new file mode 100644 index 0000000000..4501325b63 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Action.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use SebastianFeldmann\Git\Repository; + +/** + * Interface Action + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +interface Action +{ + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void; +} diff --git a/lib/captainhook/captainhook/src/Hook/Branch/Action/BlockFixupAndSquashCommits.php b/lib/captainhook/captainhook/src/Hook/Branch/Action/BlockFixupAndSquashCommits.php new file mode 100644 index 0000000000..c8f854fe28 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Branch/Action/BlockFixupAndSquashCommits.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Branch\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Git\Range\Detector\PrePush; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class BlockFixupAndSquashCommits + * + * This action blocks pushes that contain fixup! or squash! commits. + * Just as a security layer, so you are not pushing stuff you wanted to autosquash. + * + * Configure like this: + * + * { + * "action": "\\CaptainHook\\App\\Hook\\Branch\\Action\\BlockFixupAndSquashCommits", + * "options": { + * "blockSquashCommits": true, + * "blockFixupCommits": true, + * "protectedBranches": ["main", "master", "integration"] + * }, + * "conditions": [] + * } + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class BlockFixupAndSquashCommits implements Action +{ + /** + * Should fixup! commits be blocked + * + * @var bool + */ + private bool $blockFixupCommits = true; + + /** + * Should squash! commits be blocked + * + * @var bool + */ + private bool $blockSquashCommits = true; + + /** + * List of protected branches + * + * If not specified all branches are protected + * + * @var array + */ + private array $protectedBranches; + + /** + * Return hook restriction + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_PUSH]); + } + + /** + * Execute the BlockFixupAndSquashCommits action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $rangeDetector = new PrePush(); + $rangesToPush = $rangeDetector->getRanges($io); + + if (!$this->hasFoundRangesToCheck($rangesToPush)) { + return; + } + + $this->handleOptions($action->getOptions()); + + foreach ($rangesToPush as $range) { + if (!empty($this->protectedBranches) && !in_array($range->from()->branch(), $this->protectedBranches)) { + return; + } + $commits = $this->getBlockedCommits($io, $repository, $range->from()->id(), $range->to()->id()); + + if (count($commits) > 0) { + $this->handleFailure($commits, $range->from()->branch()); + } + } + } + + /** + * Check if fixup or squash should be blocked + * + * @param \CaptainHook\App\Config\Options $options + * @return void + */ + private function handleOptions(Config\Options $options): void + { + $this->blockSquashCommits = (bool) $options->get('blockSquashCommits', true); + $this->blockFixupCommits = (bool) $options->get('blockFixupCommits', true); + $this->protectedBranches = $options->get('protectedBranches', []); + } + + /** + * Returns a list of commits that should be blocked + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $remoteHash + * @param string $localHash + * @return array<\SebastianFeldmann\Git\Log\Commit> + * @throws \Exception + */ + private function getBlockedCommits(IO $io, Repository $repository, string $remoteHash, string $localHash): array + { + $typesToCheck = $this->getTypesToBlock(); + $blocked = []; + foreach ($repository->getLogOperator()->getCommitsBetween($remoteHash, $localHash) as $commit) { + $prefix = IOUtil::PREFIX_OK; + if ($this->hasToBeBlocked($commit->getSubject(), $typesToCheck)) { + $prefix = IOUtil::PREFIX_FAIL; + $blocked[] = $commit; + } + $io->write( + ' ' . $prefix . ' ' . $commit->getHash() . ' ' . $commit->getSubject(), + true, + IO::VERBOSE + ); + } + return $blocked; + } + + /** + * Returns a list of strings to look for in commit messages + * + * Will most likely return ['fixup!', 'squash!'] + * + * @return array + */ + private function getTypesToBlock(): array + { + $strings = []; + if ($this->blockFixupCommits) { + $strings[] = 'fixup!'; + } + if ($this->blockSquashCommits) { + $strings[] = 'squash!'; + } + return $strings; + } + + /** + * Checks if the commit message starts with any of the given strings + * + * @param string $message + * @param array $typesToCheck + * @return bool + */ + private function hasToBeBlocked(string $message, array $typesToCheck): bool + { + foreach ($typesToCheck as $type) { + if (str_starts_with($message, $type)) { + return true; + } + } + return false; + } + + /** + * Generate a helpful error message and throw the exception + * + * @param \SebastianFeldmann\Git\Log\Commit[] $commits + * @param string $branch + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + private function handleFailure(array $commits, string $branch): void + { + $out = []; + foreach ($commits as $commit) { + $out[] = ' - ' . $commit->getHash() . ' ' . $commit->getSubject(); + } + throw new ActionFailed( + 'You are prohibited to push the following commits:' . PHP_EOL + . ' --[ ' . $branch . ' ]-- ' . PHP_EOL + . PHP_EOL + . implode(PHP_EOL, $out) + ); + } + + /** + * Checks if we found valid ranges to check + * + * @param array<\CaptainHook\App\Git\Range\PrePush> $rangesToPush + * @return bool + */ + private function hasFoundRangesToCheck(array $rangesToPush): bool + { + if (empty($rangesToPush)) { + return false; + } + if ($rangesToPush[0]->from()->isZeroRev() || $rangesToPush[0]->to()->isZeroRev()) { + return false; + } + return true; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Branch/Action/EnsureNaming.php b/lib/captainhook/captainhook/src/Hook/Branch/Action/EnsureNaming.php new file mode 100644 index 0000000000..4a80da8185 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Branch/Action/EnsureNaming.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Branch\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class EnsureNaming + * + * @package CaptainHook + * @author Felix Edelmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.0 + */ +class EnsureNaming implements Action +{ + /** + * Return hook restriction + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_COMMIT, Hooks::PRE_PUSH, Hooks::POST_CHECKOUT]); + } + + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $regex = $this->getRegex($action->getOptions()); + $errorMsg = $this->getErrorMessage($action->getOptions()); + $successMsg = $this->getSuccessMessage($action->getOptions()); + + $branch = $repository->getInfoOperator()->getCurrentBranch(); + if (!preg_match($regex, $branch)) { + throw new ActionFailed(sprintf($errorMsg, $regex)); + } + + $io->write(['', '', sprintf($successMsg, $regex), ''], true, IO::VERBOSE); + } + + /** + * Extract regex from options array + * + * @param \CaptainHook\App\Config\Options $options + * @return string + * @throws \CaptainHook\App\Exception\ActionFailed + */ + protected function getRegex(Config\Options $options): string + { + $regex = $options->get('regex', ''); + if (empty($regex)) { + throw new ActionFailed('No regex option'); + } + return $regex; + } + + /** + * Determine the error message to use + * + * @param \CaptainHook\App\Config\Options $options + * @return string + */ + protected function getErrorMessage(Config\Options $options): string + { + $msg = $options->get('error', ''); + return !empty($msg) ? $msg : 'FAIL Branch name does not match regex: %s'; + } + + /** + * Determine the error message to use + * + * @param \CaptainHook\App\Config\Options $options + * @return string + */ + protected function getSuccessMessage(Config\Options $options): string + { + $msg = $options->get('success', ''); + return !empty($msg) ? $msg : 'OK Branch name does match regex: %s'; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Cli/Command.php b/lib/captainhook/captainhook/src/Hook/Cli/Command.php new file mode 100644 index 0000000000..0540ded52b --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Cli/Command.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Cli; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Action; +use SebastianFeldmann\Git\Repository; + +/** + * Class Notify + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.23.1 + */ +class Command implements Action +{ + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Composer/Action/CheckLockFile.php b/lib/captainhook/captainhook/src/Hook/Composer/Action/CheckLockFile.php new file mode 100644 index 0000000000..cfa0471010 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Composer/Action/CheckLockFile.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Composer\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Action; +use Exception; +use SebastianFeldmann\Git\Repository; + +/** + * Class CheckLockFile + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.0.1 + */ +class CheckLockFile implements Action +{ + /** + * Composer configuration keys that are relevant for the 'content-hash' creation + * + * @var array + */ + private $relevantKeys = [ + 'name', + 'version', + 'require', + 'require-dev', + 'conflict', + 'replace', + 'provide', + 'minimum-stability', + 'prefer-stable', + 'repositories', + 'extra', + ]; + + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $path = $action->getOptions()->get('path', getcwd()); + $name = $action->getOptions()->get('name', 'composer'); + $pathname = $path . DIRECTORY_SEPARATOR . $name; + $lockFileHash = $this->getLockFileHash($pathname . '.lock'); + $configFileHash = $this->getConfigFileHash($pathname . '.json'); + + if ($lockFileHash !== $configFileHash) { + throw new Exception('Your composer.lock file is out of date'); + } + } + + /** + * Read the composer.lock file and extract the composer.json hash + * + * @param string $composerLock + * @return string + * @throws \Exception + */ + private function getLockFileHash(string $composerLock): string + { + $lockFile = json_decode($this->loadFile($composerLock)); + $hashKey = 'content-hash'; + + if (!isset($lockFile->$hashKey)) { + throw new Exception('could not find content hash, please update composer to the latest version'); + } + + return $lockFile->$hashKey; + } + + /** + * Read the composer.json file and create a md5 hash on its relevant content + * + * This more or less is composer internal code to generate the content-hash so this might not be the best idea + * and will be removed in the future. + * + * @param string $composerJson + * @return string + * @throws \Exception + */ + private function getConfigFileHash(string $composerJson): string + { + $content = json_decode($this->loadFile($composerJson), true); + $relevantContent = []; + + foreach (array_intersect($this->relevantKeys, array_keys($content)) as $key) { + $relevantContent[$key] = $content[$key]; + } + if (isset($content['config']['platform'])) { + $relevantContent['config']['platform'] = $content['config']['platform']; + } + ksort($relevantContent); + + return md5((string)json_encode($relevantContent)); + } + + /** + * Load a composer file + * + * @param string $file + * @return string + * @throws \Exception + */ + private function loadFile(string $file): string + { + if (!file_exists($file)) { + throw new Exception($file . ' not found'); + } + return (string)file_get_contents($file); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition.php b/lib/captainhook/captainhook/src/Hook/Condition.php new file mode 100644 index 0000000000..918b5175ba --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook; + +use CaptainHook\App\Console\IO; +use SebastianFeldmann\Git\Repository; + +/** + * Interface Conditions + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +interface Condition +{ + /** + * Evaluates a condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool; +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Branch/Files.php b/lib/captainhook/captainhook/src/Hook/Condition/Branch/Files.php new file mode 100644 index 0000000000..4ac79c4301 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Branch/Files.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Branch; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hook\FileList; +use SebastianFeldmann\Git\Repository; + +/** + * Files condition + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\Branch\\Files", + * "args": [ + * {"compare-to": "main", "of-type": "php"} + * ]} + * ] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.21.0 + */ +class Files implements Condition +{ + /** + * Options + * - compare-to: source branch if known, otherwise the reflog is used to figure it out + * - in-directory: only check for files in given directory + * - of-type: only check for files of given type + * + * @var array + */ + private array $options; + + /** + * Constructor + * + * @param array $options + */ + public function __construct(array $options = []) + { + $this->options = $options; + } + + /** + * Check if the current branch contains changes to files + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + $branch = $repository->getInfoOperator()->getCurrentBranch(); + $start = $this->options['compared-to'] ?? $repository->getLogOperator()->getBranchRevFromRefLog($branch); + + if (empty($start)) { + return false; + } + + $files = $repository->getLogOperator()->getChangedFilesSince($start, ['A', 'C', 'M', 'R']); + + return count(FileList::filter($files, $this->options)) > 0; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Branch/Name.php b/lib/captainhook/captainhook/src/Hook/Condition/Branch/Name.php new file mode 100644 index 0000000000..73b7148dca --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Branch/Name.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Branch; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use SebastianFeldmann\Git\Repository; + +/** + * OnBranch condition + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +abstract class Name implements Condition +{ + /** + * Branch name to compare + * + * @var string + */ + protected string $name; + + /** + * OnBranch constructor. + * + * @param string $name + */ + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * Check is the current branch is equal to the configured one + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + abstract public function isTrue(IO $io, Repository $repository): bool; +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Branch/NotOn.php b/lib/captainhook/captainhook/src/Hook/Condition/Branch/NotOn.php new file mode 100644 index 0000000000..d30d07629f --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Branch/NotOn.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Branch; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use SebastianFeldmann\Git\Repository; + +/** + * NotOn condition + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.2 + */ +class NotOn extends Name +{ + /** + * Check is the current branch is not equal to the configured one + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return trim($repository->getInfoOperator()->getCurrentBranch()) !== $this->name; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Branch/NotOnMatching.php b/lib/captainhook/captainhook/src/Hook/Condition/Branch/NotOnMatching.php new file mode 100644 index 0000000000..59ec917c03 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Branch/NotOnMatching.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Branch; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use SebastianFeldmann\Git\Repository; + +/** + * NotOnMatching condition + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.2 + */ +class NotOnMatching extends Name +{ + /** + * Check is the current branch is not matched by the configured regex + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return preg_match($this->name, trim($repository->getInfoOperator()->getCurrentBranch())) === 0; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Branch/On.php b/lib/captainhook/captainhook/src/Hook/Condition/Branch/On.php new file mode 100644 index 0000000000..cf35f8b666 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Branch/On.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Branch; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use SebastianFeldmann\Git\Repository; + +/** + * On condition + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.2 + */ +class On extends Name +{ + /** + * Check is the current branch is equal to the configured one + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return trim($repository->getInfoOperator()->getCurrentBranch()) === $this->name; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Branch/OnMatching.php b/lib/captainhook/captainhook/src/Hook/Condition/Branch/OnMatching.php new file mode 100644 index 0000000000..532641084a --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Branch/OnMatching.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Branch; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use SebastianFeldmann\Git\Repository; + +/** + * OnMatching condition + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.2 + */ +class OnMatching extends Name +{ + /** + * Check is the current branch is matched by the configured regex + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return preg_match($this->name, trim($repository->getInfoOperator()->getCurrentBranch())) === 1; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Cli.php b/lib/captainhook/captainhook/src/Hook/Condition/Cli.php new file mode 100644 index 0000000000..7ad6b8381d --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Cli.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use SebastianFeldmann\Cli\Processor; +use SebastianFeldmann\Git\Repository; + +/** + * Class Cli + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class Cli implements Condition +{ + /** + * Binary executor + * + * @var \SebastianFeldmann\Cli\Processor + */ + private $processor; + + /** + * @var string + */ + private $command; + + /** + * Cli constructor. + * + * @param \SebastianFeldmann\Cli\Processor $processor + * @param string $command + */ + public function __construct(Processor $processor, string $command) + { + $this->processor = $processor; + $this->command = $command; + } + + /** + * Evaluates a condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + $result = $this->processor->run($this->command); + + return $result->isSuccessful(); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Config.php b/lib/captainhook/captainhook/src/Hook/Condition/Config.php new file mode 100644 index 0000000000..6524bcb521 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Config.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Config as AppConfig; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use RuntimeException; +use SebastianFeldmann\Git\Repository; + +/** + * Class FileChange + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +abstract class Config implements ConfigDependant, Condition +{ + /** + * @var \CaptainHook\App\Config|null + */ + protected ?AppConfig $config = null; + + /** + * Config setter + * + * @param \CaptainHook\App\Config $config + * @return void + */ + public function setConfig(AppConfig $config): void + { + $this->config = $config; + } + + /** + * Check if the customer value exists and return izs boolish value + * + * @param string $value + * @param bool $default + * @return bool + */ + protected function checkCustomValue(string $value, bool $default): bool + { + if (null === $this->config) { + throw new RuntimeException('config not set'); + } + $customSettings = $this->config->getCustomSettings(); + $valueToCheck = $customSettings[$value] ?? $default; + return filter_var($valueToCheck, FILTER_VALIDATE_BOOL); + } + + /** + * Evaluates a condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + abstract public function isTrue(IO $io, Repository $repository): bool; +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsFalsy.php b/lib/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsFalsy.php new file mode 100644 index 0000000000..83e26616ae --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsFalsy.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\Config; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class CustomValueIsFalsy + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\Config\\CustomValueIsFalsy", + * "args": [ + * "NAME_OF_CUSTOM_VALUE" + * ]} + * ] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.17.2 + */ +class CustomValueIsFalsy extends Condition\Config +{ + /** + * Custom config value to check + * + * @var string + */ + private string $value; + + /** + * CustomValueIsFalsy constructor + * + * @param string $value + */ + public function __construct(string $value) + { + $this->value = $value; + } + + /** + * Evaluates the condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return !$this->checkCustomValue($this->value, false); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsTruthy.php b/lib/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsTruthy.php new file mode 100644 index 0000000000..2815aa3e00 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsTruthy.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\Config; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class CustomValueIsTruthy + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\Config\\CustomValueIsTruthy", + * "args": [ + * "NAME_OF_CUSTOM_VALUE" + * ]} + * ] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.17.2 + */ +class CustomValueIsTruthy extends Condition\Config +{ + /** + * Custom config value to check + * + * @var string + */ + private string $value; + + /** + * CustomValueIsTruthy constructor + * + * @param string $value + */ + public function __construct(string $value) + { + $this->value = $value; + } + + /** + * Evaluates the condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return $this->checkCustomValue($this->value, false); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/ConfigDependant.php b/lib/captainhook/captainhook/src/Hook/Condition/ConfigDependant.php new file mode 100644 index 0000000000..d947105cc8 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/ConfigDependant.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Config; + +/** + * Interface Conditions + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.17.2 + */ +interface ConfigDependant +{ + /** + * Evaluates a condition + * + * This will be deprecated in version 6.0.0 + * In version 6.0.0 the condition interface should change to include the Config + * + * @param \CaptainHook\App\Config $config + * @return void + */ + public function setConfig(Config $config): void; +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/File.php b/lib/captainhook/captainhook/src/Hook/Condition/File.php new file mode 100644 index 0000000000..0dbbe4885b --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/File.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class FileChange + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.2.0 + */ +abstract class File implements Condition, Constrained +{ + /** + * Return the hook restriction information + * + * @return \CaptainHook\App\Hook\Restriction + */ + abstract public static function getRestriction(): Restriction; + + /** + * Evaluates a condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + abstract public function isTrue(IO $io, Repository $repository): bool; + + /** + * Check if all of the given files can be found in a haystack of files + * + * IMPORTANT: If no files are provided this is always true. + * + * @param array $files + * @param array $haystack + * @return bool + */ + protected function allFilesInHaystack(array $files, array $haystack): bool + { + foreach ($files as $filePattern) { + if (!$this->isFileInList($haystack, $filePattern)) { + return false; + } + } + return true; + } + + /** + * Check if any of the given files can be found in a haystack of files + * + * IMPORTANT: If no files are provided this is always false. + * + * @param array $files + * @param array $haystack + * @return bool + */ + protected function anyFileInHaystack(array $files, array $haystack): bool + { + foreach ($files as $filePattern) { + if ($this->isFileInList($haystack, $filePattern)) { + return true; + } + } + return false; + } + + /** + * Check if a file matching a `fnmatch` pattern was changed + * + * @param array $listOfFiles List of files to scan + * @param string $pattern Pattern in fnmatch format to look for + * @return bool + */ + protected function isFileInList(array $listOfFiles, string $pattern): bool + { + foreach ($listOfFiles as $file) { + if (fnmatch($pattern, $file)) { + return true; + } + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileChanged.php b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged.php new file mode 100644 index 0000000000..9bc371cc52 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class FileChange + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +abstract class FileChanged extends File +{ + /** + * List of file to watch + * + * @var array + */ + protected array $filesToWatch; + + /** + * Git filter options + * + * @var array + */ + private array $filter; + + /** + * FileChange constructor + * + * @param array $files + * @param string $filter + */ + public function __construct(array $files, string $filter = 'ACMR') + { + $this->filesToWatch = $files; + $this->filter = !empty($filter) ? str_split($filter) : []; + } + + /** + * Return the hook restriction information + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_PUSH, Hooks::POST_CHECKOUT, Hooks::POST_MERGE, Hooks::POST_REWRITE]); + } + + /** + * Evaluates a condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + abstract public function isTrue(IO $io, Repository $repository): bool; + + /** + * Use 'diff-tree' to find the changed files after this merge or checkout + * + * In case of a checkout it is easy because the arguments 'previousHead' and 'newHead' exist. + * In case of a merge determining this hashes is more difficult, so we are using the 'ref-log' + * to do it and using 'HEAD@{1}' as the last position before the merge and 'HEAD' as the + * current position after the merge. + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return array + */ + protected function getChangedFiles(IO $io, Repository $repository): array + { + return Git\ChangedFiles::getChangedFiles($io, $repository, $this->filter); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/All.php b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/All.php new file mode 100644 index 0000000000..e6dcc0d477 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/All.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileChanged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition\FileChanged; +use SebastianFeldmann\Git\Repository; + +/** + * Class All + * + * The FileChange condition is applicable for `post-merge` and `post-checkout` hooks. + * It checks if all configured files are updated within the last change set. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class All extends FileChanged +{ + /** + * Check if all the configured files were changed within the applied change set + * + * IMPORTANT: If no files are configured this condition is always true. + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return $this->allFilesInHaystack($this->filesToWatch, $this->getChangedFiles($io, $repository)); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/Any.php b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/Any.php new file mode 100644 index 0000000000..d40f6dd4df --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/Any.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileChanged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition\FileChanged; +use SebastianFeldmann\Git\Repository; + +/** + * Class Any + * + * The FileChange condition is applicable for `post-merge` and `post-checkout` hooks. + * For example it can be used to trigger an automatic composer install if the composer.json + * or composer.lock file is changed during a checkout or merge. + * + * Example configuration: + * + * "action": "composer install" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileChange\\Any", + * "args": [ + * [ + * "composer.json", + * "composer.lock" + * ] + * ]} + * ] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class Any extends FileChanged +{ + /** + * Check if any of the configured files was changed within the applied change set + * + * IMPORTANT: If no files are configured this condition is always false. + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return $this->anyFileInHaystack($this->filesToWatch, $this->getChangedFiles($io, $repository)); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/OfType.php b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/OfType.php new file mode 100644 index 0000000000..b13b5a8a6d --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileChanged/OfType.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileChanged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\FileList; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class OfType + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileChanged\\OfType", + * "args": [ + * "php" + * ]} + * ] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +class OfType implements Condition, Constrained +{ + /** + * File type to check e.g. 'php' or 'html' + * + * @var string + */ + private string $suffix; + + /** + * OfType constructor + * + * @param string $type + */ + public function __construct(string $type) + { + $this->suffix = $type; + } + + /** + * Return the hook restriction information + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_PUSH, Hooks::POST_CHECKOUT, Hooks::POST_MERGE, Hooks::POST_REWRITE]); + } + + /** + * Evaluates the condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + $files = FileList::filterByType( + Git\ChangedFiles::getChangedFiles($io, $repository, ['A', 'C', 'M', 'R']), + ['of-type' => $this->suffix] + ); + + if (count($files) > 0) { + return true; + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileStaged.php b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged.php new file mode 100644 index 0000000000..319a6cc9c4 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\Diff\FilterUtil; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class FileChange + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.2.0 + */ +abstract class FileStaged extends File +{ + /** + * List of file to watch + * + * @var array + */ + protected array $filesToWatch; + + /** + * --diff-filter options + * + * @var array + */ + protected array $diffFilter; + + /** + * FileStaged constructor + * + * @param mixed $files + * @param mixed $diffFilter + */ + public function __construct($files, $diffFilter = []) + { + $this->filesToWatch = is_array($files) ? $files : explode(',', (string) $files); + $this->diffFilter = FilterUtil::filterFromConfigValue($diffFilter); + } + + /** + * Return the hook restriction information + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_COMMIT]); + } + + /** + * Evaluates a condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + abstract public function isTrue(IO $io, Repository $repository): bool; + + /** + * Use 'diff-index --cached' to find the staged files before the commit + * + * @param \SebastianFeldmann\Git\Repository $repository + * @return array + */ + protected function getStagedFiles(Repository $repository): array + { + return $repository->getIndexOperator()->getStagedFiles($this->diffFilter); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/All.php b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/All.php new file mode 100644 index 0000000000..2c7e61a8d1 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/All.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileStaged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition\FileStaged; +use SebastianFeldmann\Git\Repository; + +/** + * Class All + * + * The FileStaged condition is applicable for `pre-commit` hooks. + * It checks if all configured files are staged for commit. + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\All", + * "args": [ + * ["file1", "file2", "file3"] + * ]} + * ] + * + * The file list can also be defined as comma seperated string "file1,file2,file3" + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.2.0 + */ +class All extends FileStaged +{ + /** + * Check if all the configured files are staged for commit + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return $this->allFilesInHaystack($this->filesToWatch, $this->getStagedFiles($repository)); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/Any.php b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/Any.php new file mode 100644 index 0000000000..fe65b34015 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/Any.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileStaged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition\FileStaged; +use SebastianFeldmann\Git\Repository; + +/** + * Class Any + * + * The FileStaged condition is applicable for `pre-commit hooks. + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\Any", + * "args": [ + * ["file1", "file2", "file3"] + * ]} + * ] + * + * The file list can also be defined as comma seperated string "file1,file2,file3" + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.2.0 + */ +class Any extends FileStaged +{ + /** + * Check if any of the configured files is staged for commit + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + return $this->anyFileInHaystack($this->filesToWatch, $this->getStagedFiles($repository)); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/InDirectory.php b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/InDirectory.php new file mode 100644 index 0000000000..7ddd0562ce --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/InDirectory.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileStaged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\Diff\FilterUtil; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class InDirectory + * + * All FileStaged conditions are only applicable for `pre-commit` hooks. + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\InDirectory", + * "args": [ + * "src/" + * ]} + * ] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.6.1 + */ +class InDirectory implements Condition, Constrained +{ + /** + * Directory path to check e.g. 'src/' or 'path/To/Custom/Directory/' + * + * @var string + */ + private string $directory; + + /** + * --diff-filter options + * + * @var array + */ + private array $diffFilter; + + /** + * InDirectory constructor + * + * @param mixed $directory + * @param array|string $diffFilter + */ + public function __construct($directory, $diffFilter = []) + { + $this->directory = (string) $directory; + $this->diffFilter = FilterUtil::filterFromConfigValue($diffFilter); + } + + /** + * Return the hook restriction information + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_COMMIT]); + } + + /** + * Evaluates the condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + $files = $repository->getIndexOperator()->getStagedFiles($this->diffFilter); + + $filtered = []; + foreach ($files as $file) { + if (strpos($file, $this->directory) === 0) { + $filtered[] = $file; + } + } + + return count($filtered) > 0; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/OfType.php b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/OfType.php new file mode 100644 index 0000000000..e56bf5c498 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/OfType.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileStaged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\Diff\FilterUtil; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class OfType + * + * All FileStaged conditions are only applicable for `pre-commit` hooks. + * The diff filter argument is optional. + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\OfType", + * "args": [ + * "php", + * ["A", "C"] + * ]} + * ] + * + * Multiple types can be configured using a comma separated string or an array + * "php,html,xml" + * ["php", "html", "xml"] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +class OfType implements Condition, Constrained +{ + /** + * File type to check e.g. 'php' or 'html' + * + * @var array + */ + private array $suffixes; + + /** + * --diff-filter option + * + * @var array + */ + private array $diffFilter; + + /** + * OfType constructor + * + * @param mixed $types + * @param array|string $filter + */ + public function __construct($types, $filter = []) + { + $this->suffixes = is_array($types) ? $types : explode(',', (string) $types); + $this->diffFilter = FilterUtil::filterFromConfigValue($filter); + } + + /** + * Return the hook restriction information + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_COMMIT]); + } + + /** + * Evaluates the condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + $files = $repository->getIndexOperator()->getStagedFilesOfTypes($this->suffixes, $this->diffFilter); + return count($files) > 0; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/ThatIs.php b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/ThatIs.php new file mode 100644 index 0000000000..6e749a3876 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/FileStaged/ThatIs.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Condition\FileStaged; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Git\Diff\FilterUtil; +use CaptainHook\App\Hook\Condition; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class ThatIs + * + * All FileStaged conditions are only applicable for `pre-commit` hooks. + * + * Example configuration: + * + * "action": "some-action" + * "conditions": [ + * {"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\ThatIs", + * "args": [ + * {"ofType": "php", "inDirectory": "foo/", "diff-filter": ["A", "C"]} + * ]} + * ] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.16.0 + */ +class ThatIs implements Condition, Constrained +{ + /** + * Directory path to check e.g. 'src/' or 'path/To/Custom/Directory/' + * + * @var string[] + */ + private array $directories; + + /** + * File type to check e.g. 'php' or 'html' + * + * @var string[] + */ + private array $suffixes; + + /** + * --diff-filter options + * + * @var array + */ + private array $diffFilter; + + /** + * OfType constructor + * + * @param array $options + */ + public function __construct(array $options) + { + $this->directories = (array)($options['inDirectory'] ?? []); + $this->suffixes = (array)($options['ofType'] ?? []); + + $diffFilter = $options['diffFilter'] ?? []; + $this->diffFilter = FilterUtil::sanitize(is_array($diffFilter) ? $diffFilter : str_split($diffFilter)); + } + + /** + * Return the hook restriction information + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_COMMIT]); + } + + /** + * Evaluates the condition + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return bool + */ + public function isTrue(IO $io, Repository $repository): bool + { + $files = $repository->getIndexOperator()->getStagedFiles($this->diffFilter); + $files = $this->filterFilesByDirectory($files); + $files = $this->filterFilesByType($files); + return count($files) > 0; + } + + /** + * Remove all files not in a given directory + * + * @param array $files + * @return array + */ + private function filterFilesByDirectory(array $files): array + { + if (empty($this->directories)) { + return $files; + } + return array_filter($files, function ($file) { + foreach ($this->directories as $directory) { + if (str_starts_with($file, $directory)) { + return true; + } + } + return false; + }); + } + + /** + * Remove all files not of a configured type + * + * @param array $files + * @return array + */ + private function filterFilesByType(array $files): array + { + if (empty($this->suffixes)) { + return $files; + } + return array_filter($files, fn($file) => in_array( + strtolower(pathinfo($file, PATHINFO_EXTENSION)), + $this->suffixes, + true + )); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Logic.php b/lib/captainhook/captainhook/src/Hook/Condition/Logic.php new file mode 100644 index 0000000000..e58ac02ada --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Logic.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Hook\Condition; + +/** + * Logical condition base class + * + * @package CaptainHook + * @author Sebastian Feldmann + * @author Andreas Heigl + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.7.0 + */ +abstract class Logic implements Condition +{ + /** + * List of conditions to logically connect + * + * @var \CaptainHook\App\Hook\Condition[] + */ + protected array $conditions = []; + + final private function __construct(Condition ...$conditions) + { + $this->conditions = $conditions; + } + + /** + * Create a logic condition + * + * @param array $conditions + * @return \CaptainHook\App\Hook\Condition + */ + public static function fromConditionsArray(array $conditions): Condition + { + $realConditions = []; + foreach ($conditions as $condition) { + if (! $condition instanceof Condition) { + continue; + } + $realConditions[] = $condition; + } + + return new static(...$realConditions); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Logic/LogicAnd.php b/lib/captainhook/captainhook/src/Hook/Condition/Logic/LogicAnd.php new file mode 100644 index 0000000000..34f373935d --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Logic/LogicAnd.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Logic; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition\Logic; +use SebastianFeldmann\Git\Repository; + +/** + * Connects multiple conditions with 'and' + * + * @package CaptainHook + * @author Sebastian Feldmann + * @author Andreas Heigl + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.7.0 + */ +final class LogicAnd extends Logic +{ + public function isTrue(IO $io, Repository $repository): bool + { + foreach ($this->conditions as $condition) { + if (false === $condition->isTrue($io, $repository)) { + return false; + } + } + return true; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/Logic/LogicOr.php b/lib/captainhook/captainhook/src/Hook/Condition/Logic/LogicOr.php new file mode 100644 index 0000000000..3e5a7f3f95 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/Logic/LogicOr.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition\Logic; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition\Logic; +use SebastianFeldmann\Git\Repository; + +/** + * Connects multiple conditions with 'or' + * + * @package CaptainHook + * @author Sebastian Feldmann + * @author Andreas Heigl + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.7.0 + */ +final class LogicOr extends Logic +{ + public function isTrue(IO $io, Repository $repository): bool + { + foreach ($this->conditions as $condition) { + if (true === $condition->isTrue($io, $repository)) { + return true; + } + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Condition/OnBranch.php b/lib/captainhook/captainhook/src/Hook/Condition/OnBranch.php new file mode 100644 index 0000000000..530c901c64 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Condition/OnBranch.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Condition; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition; +use SebastianFeldmann\Git\Repository; + +/** + * OnBranch condition + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + * @deprecated Replaced be CaptainHook\App\Hook\Condition\Branch\On + */ +class OnBranch extends Condition\Branch\On +{ +} diff --git a/lib/captainhook/captainhook/src/Hook/Constrained.php b/lib/captainhook/captainhook/src/Hook/Constrained.php new file mode 100644 index 0000000000..3cb4acdfdc --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Constrained.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook; + +/** + * Interface Constrained + * + * If your action is only applicable for a certain set of hooks you can limit its execution to specific hooks by + * implementing this interface and returning a restriction value object. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +interface Constrained +{ + /** + * Returns the list of hooks where this action is applicable + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction; +} diff --git a/lib/captainhook/captainhook/src/Hook/Debug.php b/lib/captainhook/captainhook/src/Hook/Debug.php new file mode 100644 index 0000000000..11e5e3e77d --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Debug.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use Exception; +use SebastianFeldmann\Git\Repository; + +/** + * Debug hook to test hook triggering + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.0.4 + */ +abstract class Debug implements Action +{ + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + abstract public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void; + + /** + * Generate some debug output + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return void + */ + protected function debugOutput(IO $io, Repository $repository): void + { + $originalHookArguments = $io->getArguments(); + + $currentGitTag = 'no tags yet'; + try { + $currentGitTag = $repository->getInfoOperator()->getCurrentTag(); + } catch (Exception $e) { + // ignore it, it just means there are no tags yet + } + $io->write($this->getArgumentOutput($originalHookArguments), false); + $io->write(' Current git-tag: ' . $currentGitTag); + $io->write( + ' StandardInput: ' . PHP_EOL . + ' ' . implode(PHP_EOL . ' ', $io->getStandardInput()) + ); + } + + /** + * Format output to display original hook arguments + * + * Returns a string with a newline character at the end. + * + * @param array $args + * @return string + */ + protected function getArgumentOutput(array $args): string + { + $out = ' Original arguments:' . PHP_EOL; + foreach ($args as $name => $value) { + $out .= ' ' . $name . ' => ' . $value . PHP_EOL; + } + return $out; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Debug/Failure.php b/lib/captainhook/captainhook/src/Hook/Debug/Failure.php new file mode 100644 index 0000000000..e3dd98279a --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Debug/Failure.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Debug; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Debug; +use SebastianFeldmann\Git\Repository; + +/** + * Debug hook to test hook triggering that fails the hook execution + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.1 + */ +class Failure extends Debug +{ + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $this->debugOutput($io, $repository); + + throw new ActionFailed( + 'The \'Debug\' action is only for debugging purposes, ' + . 'please remove the \'Debug\' action from your config' + ); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Debug/Success.php b/lib/captainhook/captainhook/src/Hook/Debug/Success.php new file mode 100644 index 0000000000..7a9d47b01d --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Debug/Success.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Debug; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Debug; +use SebastianFeldmann\Git\Repository; + +/** + * Debug hook to test hook triggering that allows the hook to pass + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.0.4 + */ +class Success extends Debug +{ + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $this->debugOutput($io, $repository); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Diff/Action/BlockSecrets.php b/lib/captainhook/captainhook/src/Hook/Diff/Action/BlockSecrets.php new file mode 100644 index 0000000000..d0c4d0d7ac --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Diff/Action/BlockSecrets.php @@ -0,0 +1,343 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Diff\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Git\Range\Detector\PrePush; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hook\Util; +use CaptainHook\App\Hooks; +use CaptainHook\Secrets\Detector; +use CaptainHook\Secrets\Entropy\Shannon; +use CaptainHook\Secrets\Regex\Supplier\Ini; +use CaptainHook\Secrets\Regex\Supplier\Json; +use CaptainHook\Secrets\Regex\Supplier\PHP; +use CaptainHook\Secrets\Regex\Supplier\Yaml; +use CaptainHook\Secrets\Regexer; +use Exception; +use SebastianFeldmann\Git\Diff\File; +use SebastianFeldmann\Git\Repository; + +class BlockSecrets implements Action, Constrained +{ + /** + * @var \CaptainHook\App\Console\IO + */ + private IO $io; + + /** + * @var \CaptainHook\Secrets\Detector + */ + private Detector $detector; + + /** + * List of allowed patterns + * + * @var array + */ + private array $allowed; + + /** + * Additional information for a file + * + * @var array + */ + private array $info = []; + + /** + * Max allowed entropy for words + * + * @var float + */ + private float $entropyThreshold; + + /** + * Map filetype regex supplier + * + * @var array + */ + private array $fileTypeSupplier = [ + 'json' => Json::class, + 'php' => PHP::class, + 'yml' => Yaml::class, + 'ini' => Ini::class, + ]; + + /** + * Make sure this action is only used pro pre-commit hooks + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return new Restriction('pre-commit', 'pre-push'); + } + + /** + * Execute the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $this->io = $io; + $this->setUp($action->getOptions()); + + $filesFailed = 0; + $filesToCheck = $this->getChanges($repository); + + foreach ($filesToCheck as $file) { + if ($this->isSecretInFile($file->getName(), $this->getLines($file))) { + $filesFailed++; + $io->write(' ' . IOUtil::PREFIX_FAIL . ' ' . $file->getName() . $this->errorDetails($file->getName())); + continue; + } + $io->write(' ' . IOUtil::PREFIX_OK . ' ' . $file->getName(), true, IO::VERBOSE); + } + if ($filesFailed > 0) { + $s = $filesFailed > 1 ? 's' : ''; + throw new ActionFailed('Found secrets in ' . $filesFailed . ' file' . $s); + } + } + + /** + * Checks if some added lines contain secrets that are not allowed + * + * @param string $file + * @param array $lines + * @return bool + */ + private function isSecretInFile(string $file, array $lines): bool + { + $result = $this->detector->detectIn(implode(PHP_EOL, $lines)); + if ($result->wasSecretDetected()) { + foreach ($result->matches() as $match) { + if (!$this->isAllowed($match)) { + $this->info[$file] = $match; + return true; + } + } + } + if ($this->containsSuspiciousText($file, $lines)) { + return true; + } + return false; + } + + /** + * Tries to find passwords by entropy + * + * @param string $file + * @param array $lines + * @return bool + */ + private function containsSuspiciousText(string $file, array $lines): bool + { + if ($this->entropyThreshold < 0.1) { + return false; + } + $ext = $this->getFileExtension($file); + // if we don't have a supplier for this filetype just exit + if (!isset($this->fileTypeSupplier[$ext])) { + return $this->lookForSecretsBruteForce($file, $lines); + } + return $this->lookForSecretsWithSupplier($this->fileTypeSupplier[$ext], $lines, $file); + } + + /** + * @param \SebastianFeldmann\Git\Diff\File $file + * @return array + */ + private function getLines(File $file): array + { + $lines = []; + foreach ($file->getChanges() as $change) { + array_push($lines, ...$change->getAddedContent()); + } + return $lines; + } + + /** + * Checks if a found blocked pattern should be allowed anyway + * + * @param string $blocked + * @return bool + */ + private function isAllowed(string $blocked): bool + { + foreach ($this->allowed as $regex) { + $matchCount = preg_match($regex, $blocked, $matches); + if ($matchCount) { + return true; + } + } + return false; + } + + /** + * Read all options and set up the action properly + * + * @param \CaptainHook\App\Config\Options $options + * @throws \CaptainHook\App\Exception\ActionFailed + */ + private function setUp(Config\Options $options): void + { + $this->detector = Detector::create(); + + $this->setUpSuppliers($options); + $this->setUpBlocked($options); + $this->entropyThreshold = (float) $options->get('entropyThreshold', 0.0); + $this->allowed = $options->get('allowed', []); + } + + /** + * Set up the blocked regex + * + * @param \CaptainHook\App\Config\Options $options + * @throws \CaptainHook\App\Exception\ActionFailed + */ + private function setUpSuppliers(Config\Options $options): void + { + try { + $this->detector->useSupplierConfig($options->get('suppliers', [])); + } catch (Exception $e) { + throw new ActionFailed($e->getMessage(), 0, $e); + } + } + + /** + * @param \CaptainHook\App\Config\Options $options + * @return void + */ + private function setUpBlocked(Config\Options $options): void + { + $this->detector->useRegex(...$options->get('blocked', [])); + } + + /** + * Return an error message appendix + * + * @param string $file + * @return string + */ + protected function errorDetails(string $file): string + { + return ' found ' . $this->info[$file] . ''; + } + + /** + * @param \SebastianFeldmann\Git\Repository $repository + * @return array<\SebastianFeldmann\Git\Diff\File> + */ + private function getChanges(Repository $repository): array + { + if (Util::isRunningHook($this->io, Hooks::PRE_PUSH)) { + $detector = new PrePush(); + $ranges = $detector->getRanges($this->io); + $newHash = 'HEAD'; + $oldHash = 'HEAD@{1}'; + if (!empty($ranges) && !$ranges[0]->to()->isZeroRev()) { + $oldHash = $ranges[0]->from()->id(); + $newHash = $ranges[0]->to()->id(); + } + return $repository->getDiffOperator()->compare($oldHash, $newHash); + } + return $repository->getDiffOperator()->compareIndexTo('HEAD'); + } + + /** + * Return the file suffix for a given file name + * + * @param string $file + * @return string + */ + private function getFileExtension(string $file): string + { + $fileInfo = pathinfo($file); + return $fileInfo['extension'] ?? ''; + } + + /** + * Should match be blocked because of entropy value + * + * @param string $file + * @param string $match + * @return bool + */ + private function isEntropyTooHigh(string $file, string $match): bool + { + $entropy = Shannon::entropy($match); + $this->io->write('Entropy of ' . $match . ' is ' . $entropy, true, IO::DEBUG); + if ($entropy > $this->entropyThreshold) { + if (!$this->isAllowed($match)) { + $this->info[$file] = $match; + return true; + } + } + return false; + } + + /** + * Uses supplier and regexer to find possible risky parts of a string + * + * @param string $supplierClass + * @param array $lines + * @param string $file + * @return bool + */ + private function lookForSecretsWithSupplier(string $supplierClass, array $lines, string $file): bool + { + /** @var \CaptainHook\Secrets\Regex\Grouped $supplier */ + $supplier = new $supplierClass(); + $regexer = Regexer::create()->useGroupedSupplier($supplier); + foreach ($lines as $line) { + $result = $regexer->detectIn($line); + if (!$result->wasSecretDetected()) { + continue; + } + if ($this->isEntropyTooHigh($file, $result->matches()[0])) { + return true; + } + } + return false; + } + + /** + * Check every word in a file if the entropy is too high + * + * @param string $file + * @param array $lines + * @return bool + */ + private function lookForSecretsBruteForce(string $file, array $lines): bool + { + $matches = []; + if (preg_match_all('#\b\S{8,}\b#', implode(' ', $lines), $matches)) { + foreach ($matches[0] as $word) { + if ($this->isEntropyTooHigh($file, $word)) { + return true; + } + } + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/EventSubscriber.php b/lib/captainhook/captainhook/src/Hook/EventSubscriber.php new file mode 100644 index 0000000000..e5ea1e9fd1 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/EventSubscriber.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook; + +use CaptainHook\App\Config\Action as ActionConfig; + +/** + * Interface EventSubscriber + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +interface EventSubscriber +{ + /** + * Returns a list of event handlers + * + * @param \CaptainHook\App\Config\Action $action + * @return array> + * @throws \Exception + */ + public static function getEventHandlers(ActionConfig $action): array; +} diff --git a/lib/captainhook/captainhook/src/Hook/File/Action/Check.php b/lib/captainhook/captainhook/src/Hook/File/Action/Check.php new file mode 100644 index 0000000000..cccfc3d994 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/File/Action/Check.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\File\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use SebastianFeldmann\Git\Repository; + +/** + * Class Check + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.1 + */ +abstract class Check implements Action, Constrained +{ + /** + * Actual action name + * + * @var string + */ + protected string $actionName; + + /** + * Make sure this action is only used pro pre-commit hooks + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return new Restriction('pre-commit'); + } + + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $this->setUp($action->getOptions()); + + $filesToCheck = $this->getFilesToCheck($repository); + $filesFailed = 0; + + if (!count($filesToCheck)) { + $io->write(' no files had to be checked', true, IO::VERBOSE); + return; + } + + foreach ($filesToCheck as $file) { + if (!$this->isValid($repository, $file)) { + $io->write(' ' . IOUtil::PREFIX_FAIL . ' ' . $file . $this->errorDetails($file)); + $filesFailed++; + continue; + } + $io->write(' ' . IOUtil::PREFIX_OK . ' ' . $file, true, IO::VERBOSE); + } + + if ($filesFailed > 0) { + throw new ActionFailed( + $this->errorMessage($filesFailed) + ); + } + } + + /** + * Setup the action, reading and validating all config settings + * + * @param \CaptainHook\App\Config\Options $options + */ + protected function setUp(Config\Options $options): void + { + // can be used in child classes to extract and validate config settings + } + + /** + * Some output appendix for every file + * + * @param string $file + * @return string + */ + protected function errorDetails(string $file): string + { + // can be used to enhance the output + return ''; + } + + /** + * Define the exception error message + * + * @param int $filesFailed + * @return string + */ + protected function errorMessage(int $filesFailed): string + { + $s = $filesFailed > 1 ? 's' : ''; + return 'Error: ' . $filesFailed . ' file' . $s . ' failed'; + } + + /** + * Determine if the file is valid + * + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $file + * @return bool + */ + abstract protected function isValid(Repository $repository, string $file): bool; + + /** + * Return the list of files that should be checked + * + * @param \SebastianFeldmann\Git\Repository $repository + * @return array + */ + protected function getFilesToCheck(Repository $repository): array + { + return $repository->getIndexOperator()->getStagedFiles(); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/File/Action/DoesNotContainRegex.php b/lib/captainhook/captainhook/src/Hook/File/Action/DoesNotContainRegex.php new file mode 100644 index 0000000000..9c464f8e21 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/File/Action/DoesNotContainRegex.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\File\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Config\Options; +use CaptainHook\App\Exception\ActionFailed; +use SebastianFeldmann\Git\Repository; + +/** + * Class DoesNotContainRegex + * + * @package CaptainHook + * @author Felix Edelmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.0 + */ +class DoesNotContainRegex extends Check +{ + /** + * Regex to check files against + * + * @var string + */ + private string $regex; + + /** + * Descriptive regex name + * + * @var mixed|string + */ + private $regexName; + + /** + * List of file types to check + * + * @var array + */ + private array $fileExtensions; + + /** + * Log of all checked files and found matches + * + * @var array + */ + private array $fileMatches = []; + + /** + * Total amount of found matches + * + * @var int + */ + private int $totalMatches = 0; + + /** + * Extract and validate config settings + * + * @param \CaptainHook\App\Config\Options $options + * @throws \CaptainHook\App\Exception\ActionFailed + */ + protected function setUp(Config\Options $options): void + { + $this->regex = $this->getRegex($options); + $this->regexName = $options->get('regexName', $this->regex); + $this->fileExtensions = $this->getFileExtensions($options); + } + + /** + * Returns a list of files to check + * + * @param \SebastianFeldmann\Git\Repository $repository + * @return array + */ + protected function getFilesToCheck(Repository $repository): array + { + // no filtering by file type, just return all staged files + if (empty($this->fileExtensions)) { + return parent::getFilesToCheck($repository); + } + + $index = $repository->getIndexOperator(); + $files = []; + foreach ($this->fileExtensions as $ext) { + $files[] = $index->getStagedFilesOfType($ext); + } + return array_merge(...$files); + } + + /** + * Tests if the given file doesn't contain invalid content + * + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $file + * @return bool + */ + protected function isValid(Repository $repository, string $file): bool + { + $fileContent = (string) file_get_contents($file); + $matchCount = (int) preg_match_all($this->regex, $fileContent, $matches); + + $this->fileMatches[$file] = $matchCount; + $this->totalMatches += $matchCount; + + return $matchCount === 0; + } + + /** + * Return an error message appendix + * + * @param string $file + * @return string + */ + protected function errorDetails(string $file): string + { + return ' (' + . $this->fileMatches[$file] . ' match' + . ($this->fileMatches[$file] > 1 ? 'es' : '') + . ')'; + } + + /** + * Define the exception error message + * + * @param int $filesFailed + * @return string + */ + protected function errorMessage(int $filesFailed): string + { + return 'Regex \'' . $this->regexName . '\' failed: ' + . 'found ' . $this->totalMatches . ' match' + . ($this->totalMatches > 1 ? 'es' : '') + . ' in ' . $filesFailed . ' file' + . ($filesFailed > 1 ? 's' : '' ); + } + + /** + * Returns the configured file extensions + * + * @param \CaptainHook\App\Config\Options $options + * @return string[] + */ + private function getFileExtensions(Options $options): array + { + $fileExtensions = $options->get('fileExtensions', []); + + if (!is_array($fileExtensions)) { + return []; + } + return $fileExtensions; + } + + /** + * Extract and check configured regex + * + * @param \CaptainHook\App\Config\Options $options + * @return mixed|string + * @throws \CaptainHook\App\Exception\ActionFailed + */ + private function getRegex(Options $options) + { + $regex = $options->get('regex', ''); + + if (empty($regex)) { + throw new ActionFailed('Missing option "regex" for DoesNotContainRegex action'); + } + return $regex; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/File/Action/Emptiness.php b/lib/captainhook/captainhook/src/Hook/File/Action/Emptiness.php new file mode 100644 index 0000000000..bc8178fc93 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/File/Action/Emptiness.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\File\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Exception\ActionFailed; +use SebastianFeldmann\Git\Repository; + +/** + * Class Check + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.10.0 + */ +abstract class Emptiness extends Check +{ + /** + * Actual action name + * + * @var string + */ + protected string $actionName; + + /** + * List of configured file patterns to watch + * + * @var array + */ + private array $filePatterns; + + /** + * Extract and validate all config settings + * + * @param \CaptainHook\App\Config\Options $options + * @throws \Exception + */ + protected function setUp(Config\Options $options): void + { + $this->filePatterns = $options->get('files', []); + if (empty($this->filePatterns)) { + throw new ActionFailed('Missing option "files" for ' . $this->actionName . ' action'); + } + } + + /** + * @param \SebastianFeldmann\Git\Repository $repository + * @return array + * @throws \Exception + */ + protected function getFilesToCheck(Repository $repository): array + { + $stagedFiles = parent::getFilesToCheck($repository); + return $this->extractFilesToCheck($this->getFilesToWatch(), $stagedFiles); + } + + /** + * Return a list of files to watch + * + * ['pattern1' => ['file1', 'file2'], 'pattern2' => ['file3']...] + * + * @return array> + * @throws \Exception + */ + private function getFilesToWatch(): array + { + $filesToWatch = []; + // collect all files that should be watched + foreach ($this->filePatterns as $glob) { + $globbed = glob($glob); + if (is_array($globbed)) { + $filesToWatch[$glob] = $globbed; + } + } + + return $filesToWatch; + } + + /** + * Extract files list from the action configuration + * + * @param array> $filesToWatch ['pattern1' => ['file1', 'file2'], 'pattern2' => ['file3']..] + * @param array $stagedFiles + * @return array + */ + private function extractFilesToCheck(array $filesToWatch, array $stagedFiles): array + { + $filesToCheck = []; + // check if any staged file should be watched + foreach ($stagedFiles as $stagedFile) { + if ($this->isFileUnderWatch($stagedFile, $filesToWatch)) { + $filesToCheck[] = $stagedFile; + } + } + return $filesToCheck; + } + + /** + * Check if a file is in the list of watched files + * + * @param string $stagedFile + * @param array> $filesToWatch + * @return bool + */ + private function isFileUnderWatch(string $stagedFile, array $filesToWatch): bool + { + // check the list of files found for each pattern + foreach ($filesToWatch as $files) { + foreach ($files as $fileToWatch) { + if ($fileToWatch === $stagedFile) { + return true; + } + } + } + return false; + } + + /** + * Returns true if the file has no contents + * + * @param string $file + * @return bool + */ + protected function isEmpty(string $file): bool + { + return filesize($file) === 0; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/File/Action/Exists.php b/lib/captainhook/captainhook/src/Hook/File/Action/Exists.php new file mode 100644 index 0000000000..c3a35ad2fb --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/File/Action/Exists.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\File\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Exception\ActionFailed; +use SebastianFeldmann\Git\Repository; + +/** + * Exists (in repository) + * + * This hook makes sure that a configured list of files exist in the repository. + * For example, you can use this to make sure you have committed some unit tests + * before pushing your changes. + * + * { + * "action": "\\CaptainHook\\App\\Hook\\File\\Action\\Exists", + * "options": { + * "files" : [ + * "tests / CaptainHook/ ** / * Test.php", + * "README.md" + * ] + * } + * } + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.3 + */ +class Exists extends Check +{ + /** + * List of files that should exist + * + * @var string[] + */ + private array $files; + + /** + * Extract and validate config settings + * + * @param \CaptainHook\App\Config\Options $options + * @throws \CaptainHook\App\Exception\ActionFailed + */ + protected function setUp(Config\Options $options): void + { + $this->files = $options->get('files', []); + if (!is_array($this->files) || empty($this->files)) { + throw new ActionFailed('no files configured'); + } + parent::setUp($options); + } + + /** + * Return the list of files that should be checked + * + * @param \SebastianFeldmann\Git\Repository $repository + * @return string[] + */ + protected function getFilesToCheck(Repository $repository): array + { + return $this->files; + } + + /** + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $file + * @return bool + */ + protected function isValid(Repository $repository, string $file): bool + { + $repoFiles = $repository->getInfoOperator()->getFilesInTree($file); + return !empty($repoFiles); + } + + /** + * Custom exception message + * + * @param int $filesFailed + * @return string + */ + protected function errorMessage(int $filesFailed): string + { + return 'Error: ' . $filesFailed . ' file(s) could not be found'; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/File/Action/IsEmpty.php b/lib/captainhook/captainhook/src/Hook/File/Action/IsEmpty.php new file mode 100644 index 0000000000..98e82d0b55 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/File/Action/IsEmpty.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\File\Action; + +use SebastianFeldmann\Git\Repository; + +/** + * Class IsEmpty + * + * @package CaptainHook + * @author Felix Edelmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.0 + */ +class IsEmpty extends Emptiness +{ + /** + * Actual action name for better error messages + * + * @var string + */ + protected string $actionName = 'IsEmpty'; + + /** + * Checks if the file is valid or not + * + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $file + * @return bool + */ + protected function isValid(Repository $repository, string $file): bool + { + return $this->isEmpty($file); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/File/Action/IsNotEmpty.php b/lib/captainhook/captainhook/src/Hook/File/Action/IsNotEmpty.php new file mode 100644 index 0000000000..81823dfad9 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/File/Action/IsNotEmpty.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\File\Action; + +use SebastianFeldmann\Git\Repository; + +/** + * Class IsNotEmpty + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.1 + */ +class IsNotEmpty extends Emptiness +{ + /** + * Actual action name for better error messages + * + * @var string + */ + protected string $actionName = 'IsNotEmpty'; + + /** + * Checks if the file is valid or not + * + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $file + * @return bool + */ + protected function isValid(Repository $repository, string $file): bool + { + return !$this->isEmpty($file); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/File/Action/MaxSize.php b/lib/captainhook/captainhook/src/Hook/File/Action/MaxSize.php new file mode 100644 index 0000000000..8ba9b15a06 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/File/Action/MaxSize.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\File\Action; + +use CaptainHook\App\Config; +use RuntimeException; +use SebastianFeldmann\Git\Repository; + +/** + * MaxSize + * + * Check all staged files for file size + * + * { + * "action": "\\CaptainHook\\App\\Hook\\File\\Action\\MaxSize", + * "options": { + * "size" : "5M" + * } + * } + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.3 + */ +class MaxSize extends Check +{ + /** + * @var int + */ + private int $maxBytes; + + /** + * File sizes for all checked files + * + * @var array + */ + private array $fileSizes = []; + + protected function setUp(Config\Options $options): void + { + $this->maxBytes = $this->toBytes($options->get('maxSize', '')); + } + + /** + * Make sure the given file is not too big + * + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $file + * @return bool + */ + protected function isValid(Repository $repository, string $file): bool + { + return !$this->isTooBig($file); + } + + /** + * Append the actual file size + * + * @param string $file + * @return string + */ + protected function errorDetails(string $file): string + { + return ' (' . $this->toMegaBytes($this->fileSizes[$file]) . ' MB)'; + } + + /** + * Custom error message + * + * @param int $filesFailed + * @return string + */ + protected function errorMessage(int $filesFailed): string + { + return $filesFailed . ' file' . ($filesFailed > 1 ? ' s are' : ' is') . ' too big'; + } + + /** + * Compare a file to configured max file size + * + * @param string $file + * @return bool + */ + private function isTooBig(string $file): bool + { + if (!file_exists($file) || is_dir($file)) { + return false; + } + + $this->fileSizes[$file] = (int) filesize($file); + + if ($this->fileSizes[$file] > $this->maxBytes) { + return true; + } + return false; + } + + /** + * Return given size in bytes + * Allowed units: + * B => byte + * K => kilobyte + * M => megabyte + * G => gigabyte + * T => terra byte + * P => peta byte + * + * e.g. + * 1K => 1024 + * 2K => 2048 + * ... + * + * @param string $value + * @throws \RuntimeException + * @return int + */ + public function toBytes(string $value): int + { + if (!preg_match('#^[0-9]*[BKMGTP]$#i', $value)) { + throw new RuntimeException('Invalid size value'); + } + $units = ['B' => 0, 'K' => 1, 'M' => 2, 'G' => 3, 'T' => 4, 'P' => 5]; + $unit = strtoupper(substr($value, -1)); + $number = intval(substr($value, 0, -1)); + + return $number * pow(1024, $units[$unit]); + } + + /** + * Display bytes in a readable format + * + * @param int $bytes + * @return float + */ + private function toMegaBytes(int $bytes): float + { + return round($bytes / 1024 / 1024, 3); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/FileList.php b/lib/captainhook/captainhook/src/Hook/FileList.php new file mode 100644 index 0000000000..a6e406cb8e --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/FileList.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook; + +/** + * Class FileList + * + * Helper class performing some manipulation operations on plain file lists. + * + * ['file1.txt', 'file2.txt' ...] + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.0 + */ +abstract class FileList +{ + /** + * Use all filters + * + * @param array $files + * @param array $options + * @return array + */ + public static function filter(array $files, array $options): array + { + $files = self::filterByType($files, $options); + $files = self::filterByDirectory($files, $options); + return self::replaceInAll($files, $options); + } + + /** + * Filter files by type + * + * @param array $files + * @param array $options + * @return array + */ + public static function filterByType(array $files, array $options): array + { + if (!isset($options['of-type'])) { + return $files; + } + + $filtered = []; + foreach ($files as $file) { + if (str_ends_with($file, $options['of-type'])) { + $filtered[] = $file; + } + } + return $filtered; + } + + /** + * Filter staged files by directory + * + * @param array $files + * @param array $options + * @return array + */ + public static function filterByDirectory(array $files, array $options): array + { + if (!isset($options['in-dir'])) { + return $files; + } + + $directory = $options['in-dir']; + $filtered = []; + foreach ($files as $file) { + if (str_starts_with($file, $directory)) { + $filtered[] = $file; + } + } + + return $filtered; + } + + /** + * Run search replace for all files + * + * @param array $files + * @param array $options + * @return array + */ + public static function replaceInAll(array $files, array $options): array + { + if (!isset($options['replace'])) { + return $files; + } + + $search = $options['replace']; + $replace = $options['with'] ?? ''; + + foreach ($files as $index => $file) { + $files[$index] = str_replace($search, $replace, $file); + } + return $files; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/Beams.php b/lib/captainhook/captainhook/src/Hook/Message/Action/Beams.php new file mode 100644 index 0000000000..941d675f46 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/Beams.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Message\RuleBook; +use SebastianFeldmann\Git\Repository; + +/** + * Class Beams + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Beams extends Book +{ + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $options = $action->getOptions(); + $book = new RuleBook(); + $book->setRules(RuleBook\RuleSet::beams( + (int) $options->get('subjectLength', 50), + (int) $options->get('bodyLineLength', 72), + (bool) $options->get('checkImperativeBeginningOnly', false) + )); + + $this->validate($book, $repository, $io); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/Book.php b/lib/captainhook/captainhook/src/Hook/Message/Action/Book.php new file mode 100644 index 0000000000..80f497da0c --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/Book.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Message\RuleBook; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Cli\Output\Util as OutputUtil; +use SebastianFeldmann\Git\Repository; + +/** + * Class Book + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class Book implements Action, Constrained +{ + /** + * Returns a list of applicable hooks + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::COMMIT_MSG]); + } + + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + abstract public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void; + + /** + * Validate the message + * + * @param \CaptainHook\App\Hook\Message\RuleBook $ruleBook + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Console\IO $io + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + protected function validate(RuleBook $ruleBook, Repository $repository, IO $io): void + { + // if this is a merge commit skip enforcing message rules + if ($repository->isMerging()) { + return; + } + + $problems = $ruleBook->validate($repository->getCommitMsg()); + + if (count($problems)) { + $this->errorOutput($problems, $io, $repository); + throw new ActionFailed('commit message validation failed'); + } + } + + /** + * Write the error message + * + * @param array $problems + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @return void + */ + private function errorOutput(array $problems, IO $io, Repository $repository): void + { + $s = count($problems) > 1 ? 's' : ''; + $io->write('found ' . count($problems) . ' problem' . $s . ' in your commit message'); + foreach ($problems as $problem) { + $io->write($this->formatProblem($problem)); + } + $io->write('--------------------------------------------[ your original message ]----'); + $io->write(OutputUtil::trimEmptyLines($repository->getCommitMsg()->getLines())); + $io->write('-------------------------------------------------------------------------'); + } + + /** + * Indent multi line problems so the lines after the first one are indented for better readability + * + * @param string $problem + * @return array + */ + private function formatProblem(string $problem): array + { + $lines = explode(PHP_EOL, $problem); + foreach ($lines as $index => $line) { + $lines[$index] = ' ' . $line; + } + return $lines; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/CacheOnFail.php b/lib/captainhook/captainhook/src/Hook/Message/Action/CacheOnFail.php new file mode 100644 index 0000000000..e5966ed61c --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/CacheOnFail.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\EventSubscriber; +use CaptainHook\App\Hook\Message\EventHandler\WriteCacheFile; +use SebastianFeldmann\Git\Repository; + +/** + * Class FailedStore + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class CacheOnFail implements Action, EventSubscriber +{ + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + // this action is just registering some event handler, so nothing to see here + } + + /** + * Returns a list of event handlers + * + * @param \CaptainHook\App\Config\Action $action + * @return array> + * @throws \Exception + */ + public static function getEventHandlers(Config\Action $action): array + { + // make sure the cache file is configured + if (empty($action->getOptions()->get('file', ''))) { + throw new ActionFailed('CacheOnFail requires \'file\' option'); + } + return [ + 'onHookFailure' => [new WriteCacheFile($action->getOptions()->get('file', ''))] + ]; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/InjectIssueKeyFromBranch.php b/lib/captainhook/captainhook/src/Hook/Message/Action/InjectIssueKeyFromBranch.php new file mode 100644 index 0000000000..3ff4f50561 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/InjectIssueKeyFromBranch.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Config\Options; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\CommitMessage; +use SebastianFeldmann\Git\Repository; + +/** + * Class PrepareFromFile + * + * Example configuration: + * { + * "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\InjectIssueKeyFromBranch", + * "options": { + * "regex": "#([A-Z]+\\-[0-9]+)#i", + * "into": "body", + * "mode": "append", + * "prefix": "\nissue: ", + * "force": true + * } + * } + * + * The regex option needs group $1 (...) to be the issue key + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.16.0 + */ +class InjectIssueKeyFromBranch implements Action, Constrained +{ + /** + * Mode constants + */ + private const MODE_APPEND = 'append'; + private const MODE_PREPEND = 'prepend'; + + /** + * Target constants + */ + private const TARGET_SUBJECT = 'subject'; + private const TARGET_BODY = 'body'; + + /** + * Returns a list of applicable hooks + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PREPARE_COMMIT_MSG]); + } + + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $options = $action->getOptions(); + $branch = $repository->getInfoOperator()->getCurrentBranch(); + $pattern = $options->get('regex', '#([A-Z]+\-[0-9]+)#i'); + $issueID = $this->extractIssueId($branch, $pattern); + + // did we actually find an issue id? + if (empty($issueID)) { + if ($options->get('force', false)) { + throw new ActionFailed('No issue key found in branch name'); + } + } + + $msg = $repository->getCommitMsg(); + + // make sure the issue key is not already in the commit message + if (stripos($msg->getSubject() . $msg->getContent(), $issueID) !== false) { + return; + } + + $repository->setCommitMsg($this->createNewCommitMessage($options, $msg, $issueID)); + } + + /** + * Extract issue id from branch name + * + * @param string $branch + * @param string $pattern + * @return string + */ + private function extractIssueId(string $branch, string $pattern): string + { + $match = []; + // can we actually find an issue id? + if (!preg_match($pattern, $branch, $match)) { + return ''; + } + return $match[1] ?? ''; + } + + /** + * Will create the new commit message with the injected issue key + * + * @param \CaptainHook\App\Config\Options $options + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @param string $issueID + * @return \SebastianFeldmann\Git\CommitMessage + */ + private function createNewCommitMessage(Options $options, CommitMessage $msg, string $issueID): CommitMessage + { + // let's figure out where to put the issueID + $target = $options->get('into', self::TARGET_BODY); + $mode = $options->get('mode', self::MODE_APPEND); + + // overwrite either subject or body + $pattern = $this->handlePrefixAndSuffix($mode, $options); + $msgData = [self::TARGET_SUBJECT => $msg->getSubject(), self::TARGET_BODY => $msg->getBody()]; + $msgData[$target] = $this->injectIssueId($issueID, $msgData[$target], $mode, $pattern); + + // combine all the parts to create a new commit message + $msgText = $msgData[self::TARGET_SUBJECT] . PHP_EOL + . PHP_EOL + . $msgData[self::TARGET_BODY] . PHP_EOL + . $msg->getComments(); + + return new CommitMessage($msgText, $msg->getCommentCharacter()); + } + + /** + * Appends or prepends the issue id to the given message part + * + * @param string $issueID + * @param string $msg + * @param string $mode + * @param string $pattern + * @return string + */ + private function injectIssueId(string $issueID, string $msg, string $mode, string $pattern): string + { + $issueID = preg_replace_callback( + '/\$(\d+)/', + function ($matches) use ($issueID) { + return $matches[1] === '1' ? $issueID : ''; + }, + $pattern + ); + + return ltrim($mode === self::MODE_PREPEND ? $issueID . $msg : $msg . $issueID); + } + + /** + * Make sure the prefix and suffix options still works even if they should not be used anymore + * + * @param string $mode + * @param \CaptainHook\App\Config\Options $options + * @return string + */ + private function handlePrefixAndSuffix(string $mode, Options $options): string + { + $space = ''; + $pattern = $options->get('pattern', ''); + if (empty($pattern)) { + $space = ' '; + $pattern = '$1'; + } + // depending on the mode use a whitespace as prefix or suffix + $prefix = $options->get('prefix', $mode == 'append' ? $space : ''); + $suffix = $options->get('suffix', $mode == 'prepend' ? $space : ''); + return $prefix . $pattern . $suffix; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/Prepare.php b/lib/captainhook/captainhook/src/Hook/Message/Action/Prepare.php new file mode 100644 index 0000000000..dfd65ffe2a --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/Prepare.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Action; +use SebastianFeldmann\Git\CommitMessage; +use SebastianFeldmann\Git\Repository; + +/** + * Class Prepare + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class Prepare implements Action +{ + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $options = $action->getOptions(); + $oldMsg = $repository->getCommitMsg(); + + if (!$repository->isMerging()) { + $repository->setCommitMsg(new CommitMessage($options->get('message', ''), $oldMsg->getCommentCharacter())); + } + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/PrepareFromFile.php b/lib/captainhook/captainhook/src/Hook/Message/Action/PrepareFromFile.php new file mode 100644 index 0000000000..e80f882700 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/PrepareFromFile.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use SebastianFeldmann\Git\CommitMessage; +use SebastianFeldmann\Git\Repository; + +/** + * Class PrepareFromFile + * + * Example configuration: + * { + * "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\PrepareFromFile" + * "options": { + * "file": ".git/CH_MSG_CACHE" + * } + * } + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class PrepareFromFile implements Action +{ + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $options = $action->getOptions(); + $cacheFile = $repository->getRoot() . '/' . $options->get('file', ''); + if (empty($options->get('file', ''))) { + throw new ActionFailed('PrepareFromFile requires \'file\' option'); + } + + if (!is_file($cacheFile)) { + return; + } + + // if there is a commit message don't do anything just delete the file + if ($repository->getCommitMsg()->isEmpty()) { + $msg = (string)file_get_contents($cacheFile); + $repository->setCommitMsg( + new CommitMessage($msg, $repository->getCommitMsg()->getCommentCharacter()) + ); + } + + if (!$options->get('keep', false)) { + unlink($cacheFile); + } + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/Regex.php b/lib/captainhook/captainhook/src/Hook/Message/Action/Regex.php new file mode 100644 index 0000000000..a900fd1ad8 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/Regex.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class Regex + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.0.0 + */ +class Regex implements Action, Constrained +{ + /** + * Return hook restriction + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::COMMIT_MSG]); + } + + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $regex = $this->getRegex($action->getOptions()); + $errorMsg = $this->getErrorMessage($action->getOptions()); + $successMsg = $this->getSuccessMessage($action->getOptions()); + $matches = []; + + if ($repository->isMerging()) { + return; + } + + if (!preg_match($regex, $repository->getCommitMsg()->getContent(), $matches)) { + throw new ActionFailed(sprintf($errorMsg, $regex)); + } + + $io->write(['', '', sprintf($successMsg, $matches[0]), ''], true, IO::VERBOSE); + } + + /** + * Extract regex from options array + * + * @param \CaptainHook\App\Config\Options $options + * @return string + * @throws \CaptainHook\App\Exception\ActionFailed + */ + protected function getRegex(Config\Options $options): string + { + $regex = $options->get('regex', ''); + if (empty($regex)) { + throw new ActionFailed('No regex option'); + } + return $regex; + } + + /** + * Determine the error message to use + * + * @param \CaptainHook\App\Config\Options $options + * @return string + */ + protected function getErrorMessage(Config\Options $options): string + { + $msg = $options->get('error', ''); + return !empty($msg) ? $msg : 'Commit message did not match regex: %s'; + } + + /** + * Determine the error message to use + * + * @param \CaptainHook\App\Config\Options $options + * @return string + */ + protected function getSuccessMessage(Config\Options $options): string + { + $msg = $options->get('success', ''); + return !empty($msg) ? $msg : 'Found matching pattern: %s'; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Action/Rules.php b/lib/captainhook/captainhook/src/Hook/Message/Action/Rules.php new file mode 100644 index 0000000000..167c09acb3 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Action/Rules.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Message\Rule; +use CaptainHook\App\Hook\Message\RuleBook; +use Exception; +use RuntimeException; +use SebastianFeldmann\Git\Repository; + +/** + * Class Rules + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Rules extends Book +{ + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $rules = $action->getOptions()->getAll(); + $book = new RuleBook(); + foreach ($rules as $rule) { + if (is_string($rule)) { + $book->addRule($this->createRule($rule)); + continue; + } + $book->addRule($this->createRuleFromConfig($rule)); + } + $this->validate($book, $repository, $io); + } + + /** + * Create a new rule + * + * @param string $class + * @param array $args + * @return \CaptainHook\App\Hook\Message\Rule + * @throws \Exception + */ + protected function createRule(string $class, array $args = []): Rule + { + // make sure the class is available + if (!class_exists($class)) { + throw new Exception('Unknown rule: ' . $class); + } + + $rule = empty($args) ? new $class() : new $class(...$args); + + // make sure the class implements the Rule interface + if (!$rule instanceof Rule) { + throw new Exception('Class \'' . $class . '\' must implement the Rule interface'); + } + + return $rule; + } + + /** + * Create a rule from a argument containing configuration + * + * @param array $config + * @return \CaptainHook\App\Hook\Message\Rule + * @throws \Exception + */ + private function createRuleFromConfig(array $config): Rule + { + if (!is_string($config[0]) || !is_array($config[1])) { + throw new RuntimeException('Invalid rule configuration'); + } + return $this->createRule($config[0], $config[1]); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/EventHandler/WriteCacheFile.php b/lib/captainhook/captainhook/src/Hook/Message/EventHandler/WriteCacheFile.php new file mode 100644 index 0000000000..ab06d3157e --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/EventHandler/WriteCacheFile.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\EventHandler; + +use CaptainHook\App\Event; +use CaptainHook\App\Event\Handler; + +/** + * Writes to commit message cache file to load it for a later commit + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class WriteCacheFile implements Handler +{ + /** + * Path to the commit message cache file + * + * @var string + */ + private $file; + + /** + * @param string $file + */ + public function __construct(string $file) + { + $this->file = $file; + } + /** + * Writes the commit message to a cache file to reuse it for the next commit + * + * @return void + */ + public function handle(Event $event) + { + $msg = $event->repository()->getCommitMsg()->getRawContent(); + $path = $event->repository()->getRoot() . '/' . $this->file; + file_put_contents($path, $msg); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule.php b/lib/captainhook/captainhook/src/Hook/Message/Rule.php new file mode 100644 index 0000000000..5ab2956097 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Interface Rule + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +interface Rule +{ + /** + * Return a hint how to pass the rule. + * + * @return string + */ + public function getHint(): string; + + /** + * Checks if a commit message passes the rule. + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool; +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/Base.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/Base.php new file mode 100644 index 0000000000..eb96694b17 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/Base.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; +use CaptainHook\App\Hook\Message\Rule; + +/** + * Class Base + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class Base implements Rule +{ + /** + * Rule hint. + * + * @var string + */ + protected $hint; + + /** + * @return string + */ + public function getHint(): string + { + return $this->hint; + } + + /** + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + abstract public function pass(CommitMessage $msg): bool; +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/Blacklist.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/Blacklist.php new file mode 100644 index 0000000000..294a1a6512 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/Blacklist.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class UseImperativeMood + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Blacklist extends Base +{ + /** + * Case sensitivity + * + * @var bool + */ + protected $isCaseSensitive; + + /** + * Blacklisted words + * + * @var array> + */ + protected $blacklist = [ + 'subject' => [], + 'body' => [], + ]; + + /** + * @var \Closure + */ + protected $stringDetection; + + /** + * Constructor + * + * @param bool $caseSensitive + */ + public function __construct(bool $caseSensitive = false) + { + $this->isCaseSensitive = $caseSensitive; + $this->hint = 'Commit message should not contain blacklisted words'; + $this->stringDetection = function (string $content, string $term): bool { + return strpos($content, $term) !== false; + }; + } + + /** + * Set body blacklist + * + * @param array $list + * @return void + */ + public function setBodyBlacklist(array $list): void + { + $this->setBlacklist($list, 'body'); + } + + /** + * Set subject blacklist + * + * @param array $list + * @return void + */ + public function setSubjectBlacklist(array $list): void + { + $this->setBlacklist($list, 'subject'); + } + + /** + * Blacklist setter + * + * @param array $list + * @param string $type + * @return void + */ + protected function setBlacklist(array $list, string $type): void + { + $this->blacklist[$type] = $list; + } + + /** + * Check if the message contains blacklisted words + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool + { + return $this->isSubjectValid($msg) && $this->isBodyValid($msg); + } + + /** + * Check commit message subject for blacklisted words + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + protected function isSubjectValid(CommitMessage $msg): bool + { + return !$this->containsBlacklistedWord($this->blacklist['subject'], $msg->getSubject()); + } + + /** + * Check commit message body for blacklisted words + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + protected function isBodyValid(CommitMessage $msg): bool + { + return !$this->containsBlacklistedWord($this->blacklist['body'], $msg->getBody()); + } + + /** + * Contains blacklisted word + * + * @param array $list + * @param string $content + * @return bool + */ + protected function containsBlacklistedWord(array $list, string $content): bool + { + if (!$this->isCaseSensitive) { + $content = strtolower($content); + $list = array_map('strtolower', $list); + } + foreach ($list as $term) { + if (($this->stringDetection)($content, $term)) { + $this->hint .= PHP_EOL . 'Invalid use of \'' . $term . '\''; + return true; + } + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/CapitalizeSubject.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/CapitalizeSubject.php new file mode 100644 index 0000000000..9d2ef710e9 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/CapitalizeSubject.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class CapitalizeSubject + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class CapitalizeSubject extends Base +{ + /** + * Constructor + */ + public function __construct() + { + $this->hint = 'Subject line has to start with an upper case letter'; + } + + /** + * Check if commit message starts with upper case letter + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool + { + if (!$msg->isEmpty()) { + $firstLetter = substr($msg->getSubject(), 0, 1); + return $firstLetter === strtoupper($firstLetter); + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/LimitBodyLineLength.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/LimitBodyLineLength.php new file mode 100644 index 0000000000..8436ea1c4c --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/LimitBodyLineLength.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class LimitBodyLineLength + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class LimitBodyLineLength extends Base +{ + /** + * Length limit + * + * @var int + */ + protected $maxLength; + + /** + * Constructor + * + * @param int $length + */ + public function __construct($length = 72) + { + $this->hint = 'Body lines should not exceed ' . $length . ' characters'; + $this->maxLength = $length; + } + + /** + * Check if a body line doesn't exceed the max length limit + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool + { + $lineNr = 1; + foreach ($msg->getBodyLines() as $line) { + if (mb_strlen($line) > $this->maxLength) { + $this->hint .= PHP_EOL . 'Line ' . $lineNr . ' of your body exceeds the max line length'; + return false; + } + $lineNr++; + } + return true; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/LimitSubjectLength.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/LimitSubjectLength.php new file mode 100644 index 0000000000..835d03c32f --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/LimitSubjectLength.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class LimitSubjectLength + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class LimitSubjectLength extends Base +{ + /** + * Length limit + * + * @var int + */ + protected $maxLength; + + /** + * Constructor + * + * @param int $length + */ + public function __construct(int $length = 50) + { + $this->hint = 'Subject line should not exceed ' . $length . ' characters'; + $this->maxLength = $length; + } + + /** + * Check if commit message doesn't exceeed the max length + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool + { + $subjectLength = mb_strlen($msg->getSubject()); + if ($subjectLength > $this->maxLength) { + $this->hint .= ' (' . $subjectLength . ')'; + return false; + } + return true; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/MsgNotEmpty.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/MsgNotEmpty.php new file mode 100644 index 0000000000..2cbc2b9e13 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/MsgNotEmpty.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class MsgNotEmpty + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class MsgNotEmpty extends Base +{ + /** + * SubjectStartsUpperCase constructor + */ + public function __construct() + { + $this->hint = 'Commit message can not be empty'; + } + + /** + * Check if commit message is not empty + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool + { + return !$msg->isEmpty(); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/NoPeriodOnSubjectEnd.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/NoPeriodOnSubjectEnd.php new file mode 100644 index 0000000000..342be23859 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/NoPeriodOnSubjectEnd.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class NoPeriodOnSubjectEnd + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class NoPeriodOnSubjectEnd extends Base +{ + /** + * Constructor + */ + public function __construct() + { + $this->hint = 'Subject should not end with a period'; + } + + /** + * Check if commit message doesn't end with a period + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool + { + return substr(trim($msg->getSubject()), -1) !== '.'; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/SeparateSubjectFromBodyWithBlankLine.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/SeparateSubjectFromBodyWithBlankLine.php new file mode 100644 index 0000000000..530c7f4c34 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/SeparateSubjectFromBodyWithBlankLine.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class SeparateSubjectFromBodyWithBlankLine + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class SeparateSubjectFromBodyWithBlankLine extends Base +{ + /** + * Constructor + */ + public function __construct() + { + $this->hint = 'Subject and body have to be separated by a blank line'; + } + + /** + * Check if subject and body are separated by a blank line + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return bool + */ + public function pass(CommitMessage $msg): bool + { + return $msg->getContentLineCount() < 2 || empty($msg->getContentLine(1)); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/Rule/UseImperativeMood.php b/lib/captainhook/captainhook/src/Hook/Message/Rule/UseImperativeMood.php new file mode 100644 index 0000000000..e1abdc78d2 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/Rule/UseImperativeMood.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\Rule; + +/** + * Class UseImperativeMood + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class UseImperativeMood extends Blacklist +{ + /** + * Constructor + * + * @param bool $checkOnlyBeginning + */ + public function __construct(bool $checkOnlyBeginning = false) + { + parent::__construct(); + + $this->hint = 'A commit message subject should always complete the following sentence.' . PHP_EOL . + 'This commit will [YOUR COMMIT MESSAGE].'; + + $this->setSubjectBlacklist( + [ + 'added', + 'changed', + 'created', + 'deleted', + 'fixed', + 'reformatted', + 'removed', + 'updated', + 'uploaded' + ] + ); + + if ($checkOnlyBeginning) { + // overwrite the detection logic to only check the beginning og the string + $this->stringDetection = function (string $content, string $term): bool { + return strpos($content, $term) === 0; + }; + } + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/RuleBook.php b/lib/captainhook/captainhook/src/Hook/Message/RuleBook.php new file mode 100644 index 0000000000..5b895145bb --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/RuleBook.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message; + +use SebastianFeldmann\Git\CommitMessage; + +/** + * Class RuleBook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class RuleBook +{ + /** + * List of rules to check + * + * @var \CaptainHook\App\Hook\Message\Rule[] + */ + private $rules = []; + + /** + * Set rules to check + * + * @param \CaptainHook\App\Hook\Message\Rule[] $rules + * @return \CaptainHook\App\Hook\Message\RuleBook + */ + public function setRules(array $rules): RuleBook + { + $this->rules = $rules; + return $this; + } + + /** + * Add a rule to the list + * + * @param \CaptainHook\App\Hook\Message\Rule $rule + * @return \CaptainHook\App\Hook\Message\RuleBook + */ + public function addRule(Rule $rule): RuleBook + { + $this->rules[] = $rule; + return $this; + } + + /** + * Validates all rules + * + * Returns a list of problems found checking the commit message. + * If the list is empty the message is valid. + * + * @param \SebastianFeldmann\Git\CommitMessage $msg + * @return array + */ + public function validate(CommitMessage $msg): array + { + $problems = []; + foreach ($this->rules as $rule) { + if (!$rule->pass($msg)) { + $problems[] = $rule->getHint(); + } + } + return $problems; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Message/RuleBook/RuleSet.php b/lib/captainhook/captainhook/src/Hook/Message/RuleBook/RuleSet.php new file mode 100644 index 0000000000..d6348ea98d --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Message/RuleBook/RuleSet.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Message\RuleBook; + +use CaptainHook\App\Hook\Message\Rule; + +/** + * Class RuleSet + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 2.1.0 + */ +abstract class RuleSet +{ + /** + * Return Beams rule set + * + * @param int $subjectLength + * @param int $bodyLineLength + * @param bool $checkImperativeBeginningOnly + * @return \CaptainHook\App\Hook\Message\Rule[] + */ + public static function beams( + int $subjectLength = 50, + int $bodyLineLength = 72, + bool $checkImperativeBeginningOnly = false + ): array { + return [ + new Rule\CapitalizeSubject(), + new Rule\LimitSubjectLength($subjectLength), + new Rule\NoPeriodOnSubjectEnd(), + new Rule\UseImperativeMood($checkImperativeBeginningOnly), + new Rule\LimitBodyLineLength($bodyLineLength), + new Rule\SeparateSubjectFromBodyWithBlankLine() + ]; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Notify/Action/IntegrateBeforePush.php b/lib/captainhook/captainhook/src/Hook/Notify/Action/IntegrateBeforePush.php new file mode 100644 index 0000000000..ee6bd598b3 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Notify/Action/IntegrateBeforePush.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Notify\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Git\Rev\Util as RevUtil; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Git\Repository; + +/** + * Class IntegrateBeforePush + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.19.1 + */ +class IntegrateBeforePush implements Action, Constrained +{ + /** + * Returns a list of applicable hooks + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::PRE_PUSH]); + } + + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $trigger = $action->getOptions()->get('trigger', '[merge]'); + $branchToWatch = $action->getOptions()->get('branch', 'origin/main'); + $branchInfo = RevUtil::extractBranchInfo($branchToWatch); + + $repository->getRemoteOperator()->fetchBranch($branchInfo['remote'], $branchInfo['branch']); + + foreach ($repository->getLogOperator()->getCommitsBetween('HEAD', $branchToWatch) as $commit) { + $message = $commit->getSubject() . PHP_EOL . $commit->getBody(); + if (str_contains($message, $trigger)) { + throw new ActionFailed('integrate ' . $branchInfo['branch'] . ' before you push!'); + } + } + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Notify/Action/Notify.php b/lib/captainhook/captainhook/src/Hook/Notify/Action/Notify.php new file mode 100644 index 0000000000..3c6a58a1a4 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Notify/Action/Notify.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Notify\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Notify\Extractor; +use CaptainHook\App\Hook\Notify\Notification; +use CaptainHook\App\Hook\Restriction; +use CaptainHook\App\Hook\Util; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Cli\Processor\ProcOpen as Processor; +use SebastianFeldmann\Git\Repository; + +/** + * Class Notify + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.5 + */ +class Notify implements Action, Constrained +{ + private const DEFAULT_PREFIX = 'git-notify:'; + + /** + * git-notify trigger + * + * @var string + */ + private $prefix; + + /** + * Returns a list of applicable hooks + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function getRestriction(): Restriction + { + return Restriction::fromArray([Hooks::POST_CHECKOUT, Hooks::POST_MERGE, Hooks::POST_REWRITE]); + } + + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $this->prefix = $action->getOptions()->get('prefix', self::DEFAULT_PREFIX); + $oldHash = Util::findPreviousHead($io); + $newHash = $io->getArgument(Hooks::ARG_NEW_HEAD, 'HEAD'); + + $logOp = $repository->getLogOperator(); + $log = $logOp->getCommitsBetween($oldHash, $newHash); + + foreach ($log as $commit) { + $message = $commit->getSubject() . PHP_EOL . $commit->getBody(); + if ($this->containsNotification($message)) { + $notification = Extractor::extractNotification($message, $this->prefix); + $this->notify($io, $notification); + } + } + } + + /** + * Checks if the commit message contains the notification prefix 'git-notify:' + * + * @param string $message + * @return bool + */ + private function containsNotification(string $message): bool + { + return strpos($message, $this->prefix) !== false; + } + + /** + * Write the notification to the + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Hook\Notify\Notification $notification + * @return void + */ + private function notify(IO $io, Notification $notification): void + { + $io->write(['', '', $notification->banner(), '']); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Notify/Extractor.php b/lib/captainhook/captainhook/src/Hook/Notify/Extractor.php new file mode 100644 index 0000000000..579afac363 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Notify/Extractor.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Notify; + +/** + * Class Extractor + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.5 + */ +class Extractor +{ + /** + * Find the notification inside a commit message and return a Notification model + * + * @param string $message + * @param string $prefix + * @return \CaptainHook\App\Hook\Notify\Notification + */ + public static function extractNotification(string $message, string $prefix = 'git-notify:'): Notification + { + return new Notification(self::getLines($message, $prefix)); + } + + /** + * @param string $message + * @param string $prefix + * @return array + */ + private static function getLines(string $message, string $prefix): array + { + $matches = []; + if (preg_match('#' . $prefix . '(.*)#is', $message, $matches)) { + $split = preg_split("/\r\n|\n|\r/", $matches[1]); + + return is_array($split) ? array_map('trim', $split) : []; + } + return []; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Notify/Notification.php b/lib/captainhook/captainhook/src/Hook/Notify/Notification.php new file mode 100644 index 0000000000..5a057fb506 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Notify/Notification.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Notify; + +/** + * Class Notification + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.5 + */ +class Notification +{ + /** + * List of rules to check + * + * @var string[] + */ + private $lines = []; + + /** + * Max line length + * + * @var int + */ + private $maxLineLength = 0; + + /** + * Constructor + * + * @param string[] $lines + */ + public function __construct(array $lines) + { + $this->lines = $lines; + foreach ($this->lines as $line) { + $lineLength = mb_strlen($line); + if ($lineLength > $this->maxLineLength) { + $this->maxLineLength = $lineLength; + } + } + } + + /** + * Return line count + * + * @return int + */ + public function length(): int + { + return count($this->lines); + } + + /** + * Returns the string to display + * + * @return string + */ + public function banner(): string + { + $text = []; + $text[] = '' . str_repeat(' ', $this->maxLineLength + 6) . ''; + $text[] = ' ' . str_repeat(' ', $this->maxLineLength) . ' '; + foreach ($this->lines as $line) { + $text[] = $this->formatLine($line); + } + $text[] = ' ' . str_repeat(' ', $this->maxLineLength) . ' '; + $text[] = '' . str_repeat(' ', $this->maxLineLength + 6) . ''; + + return PHP_EOL . implode(PHP_EOL, $text) . PHP_EOL; + } + + private function formatLine(string $line): string + { + $length = mb_strlen($line); + $left = ''; + $right = ''; + if ($length < $this->maxLineLength) { + $space = $this->maxLineLength - $length; + $left = str_repeat(' ', (int) floor($space / 2)); + $right = str_repeat(' ', (int) ceil($space / 2)); + } + return ' ' . $left . $line . $right . ' '; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/PHP/Action/Linting.php b/lib/captainhook/captainhook/src/Hook/PHP/Action/Linting.php new file mode 100644 index 0000000000..9c1d9275fd --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/PHP/Action/Linting.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\PHP\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use SebastianFeldmann\Cli\Processor\ProcOpen as Processor; +use SebastianFeldmann\Git\Repository; + +/** + * Class Linter + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.0.5 + */ +class Linting implements Action +{ + /** + * Path to php executable, default 'php' + * + * @var string + */ + private $php; + + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + // we have to provide a custom filter because we do not want to check any deleted files + $changedPHPFiles = $repository->getIndexOperator()->getStagedFilesOfType('php', ['A', 'C', 'M']); + $this->php = !empty($config->getPhpPath()) ? $config->getPhpPath() : 'php'; + $failedFilesCount = 0; + + foreach ($changedPHPFiles as $file) { + $prefix = IOUtil::PREFIX_OK; + if ($this->hasSyntaxErrors($file)) { + $failedFilesCount++; + $io->write(' ' . IOUtil::PREFIX_FAIL . ' ' . $file, true, IO::NORMAL); + } + $io->write(' ' . $prefix . ' ' . $file, true, IO::VERBOSE); + } + + if ($failedFilesCount > 0) { + $s = $failedFilesCount > 1 ? 's' : ''; + throw new ActionFailed( + 'Linting failed: PHP syntax errors in ' . $failedFilesCount . ' file' . $s + ); + } + } + + /** + * Lint a php file + * + * @param string $file + * @return bool + */ + protected function hasSyntaxErrors(string $file): bool + { + $process = new Processor(); + $result = $process->run($this->php . ' -l ' . escapeshellarg($file)); + + return !$result->isSuccessful(); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/PHP/Action/TestCoverage.php b/lib/captainhook/captainhook/src/Hook/PHP/Action/TestCoverage.php new file mode 100644 index 0000000000..ba69b05baa --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/PHP/Action/TestCoverage.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\PHP\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\PHP\CoverageResolver; +use SebastianFeldmann\Git\Repository; + +/** + * Class TestCoverage + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.2.0 + */ +class TestCoverage implements Action +{ + /** + * Clover XML file + * + * @var string + */ + private string $cloverXmlFile; + + /** + * Path to PHPUnit + * + * @var string + */ + private string $phpUnit; + + /** + * Minimum coverage in percent + * + * @var int + */ + private int $minCoverage; + + /** + * Executes the action. + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $io->write('checking coverage:', true, IO::VERBOSE); + $this->handleOptions($action->getOptions()); + + $coverageResolver = $this->getCoverageResolver(); + $coverage = $coverageResolver->getCoverage(); + + $this->verifyCoverage($coverage); + $io->write('Test coverage: ' . $coverage . '%', true, IO::VERBOSE); + } + + /** + * Setup local properties with given options. + * + * @param \CaptainHook\App\Config\Options $options + * @return void + * @throws \RuntimeException + */ + protected function handleOptions(Config\Options $options): void + { + $this->cloverXmlFile = $options->get('cloverXml', ''); + $this->phpUnit = $options->get('phpUnit', 'phpunit'); + $this->minCoverage = (int) $options->get('minCoverage', 80); + } + + /** + * Return the adequate coverage resolver. + * + * @return \CaptainHook\App\Hook\PHP\CoverageResolver + */ + protected function getCoverageResolver(): CoverageResolver + { + // if clover xml is configured use it to read coverage data + if (!empty($this->cloverXmlFile)) { + return new CoverageResolver\CloverXML($this->cloverXmlFile); + } + + // no clover xml so use phpunit to get current test coverage + return new CoverageResolver\PHPUnit($this->phpUnit); + } + + /** + * Check if current coverage is high enough. + * + * @param float $coverage + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + protected function verifyCoverage(float $coverage): void + { + if ($coverage < $this->minCoverage) { + throw new ActionFailed( + 'Test coverage to low!' . PHP_EOL . + 'Current coverage is at ' . $coverage . '% but should be at least ' . $this->minCoverage . '%' + ); + } + } +} diff --git a/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver.php b/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver.php new file mode 100644 index 0000000000..3831ae4be4 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\PHP; + +/** + * Interface CoverageResolver + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.2.0 + */ +interface CoverageResolver +{ + /** + * Return test coverage in percent. + * + * @return int + */ + public function getCoverage(): int; +} diff --git a/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver/CloverXML.php b/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver/CloverXML.php new file mode 100644 index 0000000000..f7d9e82e6a --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver/CloverXML.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\PHP\CoverageResolver; + +use RuntimeException; +use CaptainHook\App\Hook\PHP\CoverageResolver; +use CaptainHook\App\Storage\File\Xml; + +/** + * Class CloverXML + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.2.0 + */ +class CloverXML implements CoverageResolver +{ + /** + * Clover XML + * + * @var \SimpleXMLElement + */ + private $xml; + + /** + * CloverXML constructor. + * + * @param string $pathToCloverXml + */ + public function __construct($pathToCloverXml) + { + $cloverFile = new Xml($pathToCloverXml); + if (!$cloverFile->exists()) { + throw new RuntimeException('could not find clover xml file: ' . $cloverFile->getPath()); + } + $this->xml = $cloverFile->read(); + $this->validateXml(); + } + + /** + * Make sure you have a valid xml structure + * + * @return void + * @throws \RuntimeException + */ + private function validateXml(): void + { + if (!isset($this->xml->project) || !isset($this->xml->project->metrics)) { + throw new RuntimeException('invalid clover xml file'); + } + } + + /** + * Return test coverage in percent. + * + * @return int + */ + public function getCoverage(): int + { + $xmlStatements = (string) $this->xml->project->metrics->attributes()->statements; + $xmlCovered = (string) $this->xml->project->metrics->attributes()->coveredstatements; + + if (!is_numeric($xmlStatements) || !is_numeric($xmlCovered)) { + throw new RuntimeException( + 'could not read coverage data from clover xml file ' . + '(statements: ' . $xmlStatements . ', coveredstatements: ' . $xmlCovered . ')' + ); + } + + $statements = (int) $xmlStatements; + $covered = (int) $xmlCovered; + + if ($statements < 1) { + throw new RuntimeException( + 'zero statements found ' . + '(statements: ' . $xmlStatements . ', coveredstatements: ' . $xmlCovered . ')' + ); + } + + return (int) ceil(($covered / $statements) * 100); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver/PHPUnit.php b/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver/PHPUnit.php new file mode 100644 index 0000000000..97dfeb658c --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/PHP/CoverageResolver/PHPUnit.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\PHP\CoverageResolver; + +use RuntimeException; +use CaptainHook\App\Hook\PHP\CoverageResolver; +use SebastianFeldmann\Cli\Processor\ProcOpen as Processor; + +/** + * Class PHPUnit + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.2.0 + */ +class PHPUnit implements CoverageResolver +{ + /** + * Path to phpunit + * + * @var string + */ + private $phpUnit; + + /** + * PHPUnit constructor. + * + * @param string $pathToPHPUnit + */ + public function __construct(string $pathToPHPUnit) + { + $this->phpUnit = $pathToPHPUnit; + } + + /** + * Run PHPUnit to calculate code coverage. + * Shamelessly ripped from bruli/php-git-hooks. + * + * @author Pablo Braulio + * @return int + */ + public function getCoverage(): int + { + $processor = new Processor(); + $result = $processor->run($this->phpUnit . ' --coverage-text|grep Classes|cut -d " " -f 4|cut -d "%" -f 1'); + $output = $result->getStdOut(); + if (!$result->isSuccessful() || empty($output)) { + throw new RuntimeException('Error while executing PHPUnit: ' . $result->getStdErr()); + } + return (int) ceil((float) $output); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Restriction.php b/lib/captainhook/captainhook/src/Hook/Restriction.php new file mode 100644 index 0000000000..75dc41540c --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Restriction.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook; + +use CaptainHook\App\Hooks; + +/** + * Class PHP + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +final class Restriction +{ + /** + * List of applicable hooks + * + * Map HookName => isApplicable to not have to scan the whole array for hook names. + * + * @var array + */ + private array $applicableHooks; + + /** + * Restriction constructor + * + * @param string ...$hooks + */ + public function __construct(string ...$hooks) + { + foreach ($hooks as $hook) { + $this->allowHook($hook); + } + } + + /** + * Add an allowed hook to the restriction + * + * If the Restrictions is already applicable it returns itself + * if not the current instance get cloned and the new hook is + * added to the applicable hook list. + * + * @param string $hook + * @return \CaptainHook\App\Hook\Restriction + */ + public function with(string $hook): self + { + if ($this->isApplicableFor($hook)) { + return $this; + } + + $restriction = clone ($this); + $restriction->allowHook($hook); + return $restriction; + } + + /** + * Check if a given hook is applicable for this restriction + * + * @param string $hook + * @return bool + */ + public function isApplicableFor(string $hook): bool + { + return $this->applicableHooks[$hook] ?? false; + } + + /** + * Add hook to allow execution, invalid hooks will be ignored + * + * @param string $hook + */ + private function allowHook(string $hook): void + { + if (Util::isValid($hook)) { + $this->applicableHooks[$hook] = true; + // also allow all native hook if a virtual hook is used + foreach (Hooks::getNativeHooksForVirtualHook($hook) as $nativeHook) { + $this->applicableHooks[$nativeHook] = true; + } + } + } + + /** + * Create restriction from array + * + * @param array $hooks + * @return \CaptainHook\App\Hook\Restriction + */ + public static function fromArray(array $hooks): Restriction + { + return new self(...$hooks); + } + + /** + * Create restriction from string + * + * @param string $hook + * @return \CaptainHook\App\Hook\Restriction + */ + public static function fromString(string $hook): self + { + return new self($hook); + } + + /** + * Create empty restriction + * + * @return \CaptainHook\App\Hook\Restriction + */ + public static function empty(): self + { + return new self(...[]); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Template.php b/lib/captainhook/captainhook/src/Hook/Template.php new file mode 100644 index 0000000000..d7b5fd4d9c --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook; + +/** + * Template interface + * + * Templates generate the hook sourcecode to place in .git/hooks/* to execute CaptainHook. + * There are 3 types of templates: + * - SHELL Writes a shell script, this is the recommended way for all unix or linux based systems. + * - PHP Writes a PHP script, this is useful if you are running windows and shell scripts aren't an option. + * - DOCKER Writes a shell script that executes captainhook inside a docker container. This is useful if you + * don't want to install PHP locally. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.3.0 + */ +interface Template +{ + public const SHELL = 'shell'; + public const PHP = 'php'; + public const DOCKER = 'docker'; + public const WSL = 'wsl'; + + /** + * Return the code for the git hook scripts + * + * @param string $hook Name of the hook to generate the sourcecode for + * @return string + */ + public function getCode(string $hook): string; +} diff --git a/lib/captainhook/captainhook/src/Hook/Template/Builder.php b/lib/captainhook/captainhook/src/Hook/Template/Builder.php new file mode 100644 index 0000000000..a783a7b5ee --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template/Builder.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Template; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\Runtime\Resolver; +use CaptainHook\App\Hook\Template; +use CaptainHook\App\Hook\Template\Local\PHP; +use CaptainHook\App\Hook\Template\Local\Shell; +use CaptainHook\App\Hook\Template\Local\WSL; +use CaptainHook\App\Runner\Bootstrap\Util; +use RuntimeException; +use SebastianFeldmann\Git\Repository; + +/** + * Builder class + * + * Creates git hook Template objects regarding some provided input. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.3.0 + */ +abstract class Builder +{ + /** + * Creates a template that is responsible for the git hook sourcecode + * + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Console\Runtime\Resolver $resolver + * @return \CaptainHook\App\Hook\Template + */ + public static function build(Config $config, Repository $repository, Resolver $resolver): Template + { + $pathInfo = new PathInfo( + $repository->getRoot(), + $config->getPath(), + $resolver->getExecutable(), + $resolver->isPharRelease() + ); + Util::validateBootstrapPath($resolver->isPharRelease(), $config); + + switch ($config->getRunConfig()->getMode()) { + case Template::DOCKER: + return new Docker($pathInfo, $config); + case Template::PHP: + return new PHP($pathInfo, $config); + case Template::WSL: + return new WSL($pathInfo, $config); + default: + return new Shell($pathInfo, $config); + } + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Template/Docker.php b/lib/captainhook/captainhook/src/Hook/Template/Docker.php new file mode 100644 index 0000000000..09fab91282 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template/Docker.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Template; + +use CaptainHook\App\CH; +use CaptainHook\App\Config; +use CaptainHook\App\Hook\Template; +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Bootstrap\Util; + +/** + * Docker class + * + * Generates the bash scripts placed in .git/hooks/* for every hook + * to execute CaptainHook inside a Docker container. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.3.0 + */ +class Docker implements Template +{ + /** + * All path required for template creation + * + * @var \CaptainHook\App\Hook\Template\PathInfo + */ + private PathInfo $pathInfo; + + /** + * CaptainHook configuration + * + * @var \CaptainHook\App\Config + */ + private Config $config; + + /** + * Docker constructor + * + * @param \CaptainHook\App\Hook\Template\PathInfo $pathInfo + * @param \CaptainHook\App\Config $config + */ + public function __construct(PathInfo $pathInfo, Config $config) + { + $this->pathInfo = $pathInfo; + $this->config = $config; + } + + /** + * Return the code for the git hook scripts + * + * @param string $hook Name of the hook to generate the sourcecode for + * @return string + */ + public function getCode(string $hook): string + { + $path2Config = $this->pathInfo->getConfigPath(); + $config = $path2Config !== CH::CONFIG ? ' --configuration=' . escapeshellarg($path2Config) : ''; + $bootstrap = Util::bootstrapCmdOption($this->pathInfo->isPhar(), $this->config); + + $lines = [ + '#!/bin/sh', + '', + '# installed by CaptainHook ' . CH::VERSION, + '', + '# if necessary read original hook stdIn to pass it in as --input option', + Hooks::receivesStdIn($hook) ? 'input=$(cat)' : 'input=""', + '', + 'if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then', + ' exec < /dev/tty', + 'fi', + '', + $this->getOptimizeDockerCommand($hook) . ' ' + . $this->resolveBinaryPath() + . $config + . $bootstrap + . ' --input=\""$input"\"' + . ' hook:' . $hook + . ' "$@"' + ]; + return implode(PHP_EOL, $lines) . PHP_EOL; + } + + + /** + * Returns the optimized docker exec command + * + * This tries to optimize the `docker exec` commands. Docker exec should always run in --interactive mode. + * During hooks that could need user input it should use --tty. + * In case of `commit -a` we have to pass the GIT_INDEX_FILE env variable so `git` inside the container + * can recognize the temp index. + * + * @param string $hook + * @return string + */ + private function getOptimizeDockerCommand(string $hook): string + { + $command = $this->config->getRunConfig()->getDockerCommand(); + $position = strpos($command, 'docker exec'); + // add interactive and tty flags if docker exec is used + if ($position !== false) { + $endExec = $position + 11; + $executable = substr($command, 0, $endExec); + $options = substr($command, $endExec); + + $command = trim($executable) + . $this->createInteractiveOptions($options) + . $this->createTTYOptions($options) + . $this->createEnvOptions($options, $hook) + . ' ' . trim($options); + } + return $command; + } + + /** + * Creates the TTY options if needed + * + * @param string $options + * @return string + */ + private function createTTYOptions(string $options): string + { + // Because this currently breaks working with Jetbrains IDEs it is deactivated for now + // $tty = Hooks::allowsUserInput($hook) ? ' -t' : ''; + // $useTTY = !preg_match('# -[a-z]*t| --tty#', $options) ? $tty : ''; + return ''; + } + + /** + * Create the env settings if needed + * + * Only force the -e option for pre-commit hooks because with `commit -a` needs + * the GIT_INDEX_FILE environment variable. + * + * @param string $options + * @param string $hook + * @return string + */ + private function createEnvOptions(string $options, string $hook): string + { + return ( + $this->hasGitPathMappingConfigured() + && $this->dockerHasNoEnvSettings($options) + && in_array($hook, [Hooks::PRE_COMMIT, Hooks::PREPARE_COMMIT_MSG]) + ) + ? ' -e GIT_INDEX_FILE="' . $this->config->getRunConfig()->getGitPath() . '/$(basename $GIT_INDEX_FILE)"' + : ''; + } + + /** + * Checks if the ENV settings are present + * + * @param string $options + * @return bool + */ + private function dockerHasNoEnvSettings(string $options): bool + { + return !preg_match('# (-[a-z]*e|--env)[= ]+GIT_INDEX_FILE#', $options); + } + + /** + * Creates the interactive option if needed, returns ' -i' or '' + * + * @param string $options + * @return string + */ + private function createInteractiveOptions(string $options): string + { + return $this->dockerIsNotInteractive($options) ? ' -i' : ''; + } + + /** + * Checks if the interactive flag is set + * + * @param string $options + * @return bool + */ + private function dockerIsNotInteractive(string $options): bool + { + return !preg_match('# -[a-z]*i| --interactive#', $options); + } + + /** + * Check if a git path is configured + * + * @return bool + */ + private function hasGitPathMappingConfigured(): bool + { + return !empty($this->config->getRunConfig()->getGitPath()); + } + + /** + * Resolves the path to the captainhook binary and returns it + * + * @return string + */ + private function resolveBinaryPath(): string + { + // if a specific executable is configured use just that + if (!empty($this->config->getRunConfig()->getCaptainsPath())) { + return $this->config->getRunConfig()->getCaptainsPath(); + } + + // For Docker, we need to strip down the current working directory. + // This is caused because docker will always connect to a specific working directory + // where the absolute path will not be recognized. + // E.g.: + // cwd => /project/ + // path => /project/vendor/bin/captainhook + // docker => ./vendor/bin/captainhook + // if the executable is located inside the repository we can use a relative path + // by default this should return something like ./vendor/bin/captainhook + // if the executable is not located in your git repository it will return the absolute path + // which will most likely not work from within the docker container + // you have to use the 'run' 'path' config then + return $this->pathInfo->getExecutablePath(); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Template/Inspector.php b/lib/captainhook/captainhook/src/Hook/Template/Inspector.php new file mode 100644 index 0000000000..a0b0a5668d --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template/Inspector.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Template; + +use CaptainHook\App\CH; +use CaptainHook\App\Console\IO; +use Exception; +use SebastianFeldmann\Git\Repository; + +/** + * Inspector + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.22.0 + */ +class Inspector +{ + /** + * @var string + */ + private string $hook; + + /** + * @var \CaptainHook\App\Console\IO + */ + private IO $io; + + /** + * @var \SebastianFeldmann\Git\Repository + */ + private Repository $repository; + + /** + * @param string $hook + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repo + */ + public function __construct(string $hook, IO $io, Repository $repo) + { + $this->hook = $hook; + $this->io = $io; + $this->repository = $repo; + } + + /** + * Check if the hooks script needs an update + * + * @return void + * @throws \Exception + */ + public function inspect(): void + { + $path = $this->repository->getHooksDir() . '/' . $this->hook; + // hook script not installed or at different location + if (!file_exists($path)) { + return; + } + + $hookScript = file_get_contents($path); + $installerVersion = $this->detectInstallerVersion((string) $hookScript); + + // could not find any installer version + // this is not optimal but if people decide to customise there is only so much I can do + if (empty($installerVersion)) { + return; + } + + if (version_compare($installerVersion, CH::MIN_REQ_INSTALLER) < 0) { + $this->io->write([ + 'Warning: Hook script is out of date', + 'The git hook script needs to be updated.', + 'Required version is ' . CH::MIN_REQ_INSTALLER . '' + . ' found ' . $installerVersion . '.', + 'Please re-install your hook by running:', + ' captainhook install ' . $this->hook . '', + '', + 'captainhook failed executing your hooks', + ]); + throw new Exception('hook code out of date'); + } + } + + /** + * Try to find the version that installed the hook script + * + * @param string $hookScript + * @return string + */ + private function detectInstallerVersion(string $hookScript): string + { + $version = ''; + $matches = []; + + if (preg_match('#installed by Captainhook ([0-9]+\.[0-9]+\.[0-9]+)#i', $hookScript, $matches)) { + $version = $matches[1]; + } + return $version; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Template/Local.php b/lib/captainhook/captainhook/src/Hook/Template/Local.php new file mode 100644 index 0000000000..a2a27b4c2a --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template/Local.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Template; + +use CaptainHook\App\Config; +use CaptainHook\App\Hook\Template; +use CaptainHook\App\Runner\Bootstrap\Util; + +abstract class Local implements Template +{ + /** + * All template related path information + * + * @var \CaptainHook\App\Hook\Template\PathInfo + */ + protected PathInfo $pathInfo; + + /** + * CaptainHook configuration + * + * @var \CaptainHook\App\Config + */ + protected Config $config; + + /** + * Local constructor + * + * @param \CaptainHook\App\Hook\Template\PathInfo $pathInfo + * @param \CaptainHook\App\Config $config + */ + public function __construct(PathInfo $pathInfo, Config $config) + { + $this->pathInfo = $pathInfo; + $this->config = $config; + } + + /** + * Return the code for the git hook scripts + * + * @param string $hook Name of the hook to generate the sourcecode for + * @return string + */ + public function getCode(string $hook): string + { + return implode(PHP_EOL, $this->getHookLines($hook)) . PHP_EOL; + } + + /** + * Returns the bootstrap option depending on the current runtime (can be empty) + * + * @return string + */ + public function getBootstrapCmdOption(): string + { + return Util::bootstrapCmdOption($this->pathInfo->isPhar(), $this->config); + } + + /** + * Return the code for the git hook scripts + * + * @param string $hook Name of the hook to generate the sourcecode for + * @return array + */ + abstract protected function getHookLines(string $hook): array; +} diff --git a/lib/captainhook/captainhook/src/Hook/Template/Local/PHP.php b/lib/captainhook/captainhook/src/Hook/Template/Local/PHP.php new file mode 100644 index 0000000000..617533403a --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template/Local/PHP.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Template\Local; + +use CaptainHook\App\CH; +use CaptainHook\App\Hook\Template; +use CaptainHook\App\Hooks; +use SebastianFeldmann\Camino\Path; +use SebastianFeldmann\Camino\Path\Directory; + +/** + * Local class + * + * Generates the sourcecode for the php hook scripts in .git/hooks/*. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.3.0 + */ +class PHP extends Template\Local +{ + /** + * Return the code for the git hook scripts + * + * @param string $hook Name of the hook to generate the sourcecode for + * @return array + */ + public function getHookLines(string $hook): array + { + return $this->pathInfo->isPhar() ? $this->getPharHookLines($hook) : $this->getSrcHookLines($hook); + } + + /** + * Returns lines of code for the local src installation + * + * @param string $hook + * @return array + */ + private function getSrcHookLines(string $hook): array + { + $configPath = $this->pathInfo->getConfigPath(); + $bootstrap = $this->config->getBootstrap(); + $stdIn = $this->getStdInHandling($hook); + + return array_merge( + [ + '#!/usr/bin/env php', + 'run(new ArgvInput($argv));', + '}', + ')($argv);', + ] + ); + } + + /** + * Returns the lines of code for the local phar installation + * + * @param string $hook + * @return array + */ + private function getPharHookLines(string $hook): array + { + $configPath = $this->pathInfo->getConfigPath(); + $executablePath = $this->pathInfo->getExecutablePath(); + $stdIn = $this->getStdInHandling($hook); + + $executableInclude = substr($executablePath, 0, 1) == '/' + ? '\'' . $executablePath . '\'' + : '__DIR__ . \'/../../' . $executablePath . '\''; + + $bootstrapOption = $this->getBootstrapCmdOption(); + $bootstrapOptionQuoted = empty($bootstrapOption) ? '' : ' \'' . $bootstrapOption . '\','; + + return array_merge( + [ + '#!/usr/bin/env php', + ' + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Template\Local; + +use CaptainHook\App\CH; +use CaptainHook\App\Hook\Template; +use CaptainHook\App\Hooks; + +/** + * Shell class + * + * Generates the sourcecode for the php hook scripts in .git/hooks/*. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +class Shell extends Template\Local +{ + /** + * Returns lines of code for the local src installation + * + * @param string $hook + * @return array + */ + protected function getHookLines(string $hook): array + { + return [ + '#!/bin/sh', + '', + '# installed by CaptainHook ' . CH::VERSION, + '', + 'INTERACTIVE="--no-interaction"', + '', + '# if necessary read original hook stdIn to pass it in as --input option', + Hooks::receivesStdIn($hook) ? 'input=$(cat)' : 'input=""', + '', + 'if [ -t 1 ]; then', + ' # If we\'re in a terminal, redirect stdout and stderr to /dev/tty and', + ' # read stdin from /dev/tty. Allow interactive mode for CaptainHook.', + ' exec >/dev/tty 2>/dev/tty getExecutable() + . ' $INTERACTIVE' + . ' --configuration=' . $this->pathInfo->getConfigPath() + . $this->getBootstrapCmdOption() + . ' --input="$input"' + . ' hook:' . $hook . ' "$@"' + ]; + } + + /** + * Returns the path to the executable including a configured php executable + * + * @return string + */ + protected function getExecutable(): string + { + $executable = !empty($this->config->getPhpPath()) ? $this->config->getPhpPath() . ' ' : ''; + + if (!empty($this->config->getRunConfig()->getCaptainsPath())) { + return $executable . $this->config->getRunConfig()->getCaptainsPath(); + } + + return $executable . $this->pathInfo->getExecutablePath(); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Template/Local/WSL.php b/lib/captainhook/captainhook/src/Hook/Template/Local/WSL.php new file mode 100644 index 0000000000..a790d2d4ca --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template/Local/WSL.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Hook\Template\Local; + +/** + * WSL class + * + * Generates the sourcecode for the php hook scripts in .git/hooks/*. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @author Christoph Kappestein + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.23.0 + */ +class WSL extends Shell +{ + protected function getExecutable(): string + { + return 'wsl.exe ' . parent::getExecutable(); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Template/PathInfo.php b/lib/captainhook/captainhook/src/Hook/Template/PathInfo.php new file mode 100644 index 0000000000..cd3563c8a2 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Template/PathInfo.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\Template; + +use SebastianFeldmann\Camino\Check; +use SebastianFeldmann\Camino\Path; +use SebastianFeldmann\Camino\Path\Directory; +use SebastianFeldmann\Camino\Path\File; + +class PathInfo +{ + /** + * Absolute path to repository + * @var string + */ + private string $repositoryAbsolute; + + /** + * @var \SebastianFeldmann\Camino\Path\Directory + */ + protected Directory $repository; + + /** + * Absolute path to config file + * @var string + */ + private string $configAbsolute; + + /** + * @var \SebastianFeldmann\Camino\Path\File + */ + protected File $config; + + /** + * Absolute path to captainhook executable + * @var string + */ + protected string $executableAbsolute; + + /** + * @var \SebastianFeldmann\Camino\Path\File + */ + private File $executable; + + /** + * PHAR or composer runtime + * + * @var bool + */ + private bool $isPhar; + + /** + * @param string $repositoryPath + * @param string $configPath + * @param string $execPath + * @param bool $isPhar + */ + public function __construct(string $repositoryPath, string $configPath, string $execPath, bool $isPhar) + { + $this->repositoryAbsolute = self::toAbsolutePath($repositoryPath); + $this->repository = new Directory($this->repositoryAbsolute); + $this->configAbsolute = self::toAbsolutePath($configPath); + $this->config = new File($this->configAbsolute); + $this->executableAbsolute = self::toAbsolutePath($execPath); + $this->executable = new File($this->executableAbsolute); + $this->isPhar = $isPhar; + } + + /** + * Returns the path to the captainhook executable + * + * @return string + */ + public function getExecutablePath(): string + { + // check if the captainhook binary is in the repository bin directory + // this should only be the case if we work in the captainhook repository + if (file_exists($this->repositoryAbsolute . '/bin/captainhook')) { + return './bin/captainhook'; + } + return $this->getPathFromTo($this->repository, $this->executable); + } + + /** + * Returns the path to the captainhook configuration file + * @return string + */ + public function getConfigPath(): string + { + return $this->getPathFromTo($this->repository, $this->config); + } + + /** + * Runtime indicator + * + * @return bool + */ + public function isPhar(): bool + { + return $this->isPhar; + } + + /** + * Return the path to the target path from inside the .git/hooks directory f.e. __DIR__ ../../vendor + * + * @param \SebastianFeldmann\Camino\Path\Directory $repo + * @param \SebastianFeldmann\Camino\Path $target + * @return string + */ + private function getPathFromTo(Directory $repo, Path $target): string + { + if (!$target->isChildOf($repo)) { + return $target->getPath(); + } + return $target->getRelativePathFrom($repo); + } + + + /** + * Make sure the given path is absolute + * + * @param string $path + * @return string + */ + private static function toAbsolutePath(string $path): string + { + if (Check::isAbsolutePath($path)) { + return $path; + } + return (string) realpath($path); + } +} diff --git a/lib/captainhook/captainhook/src/Hook/UserInput/AskConfirmation.php b/lib/captainhook/captainhook/src/Hook/UserInput/AskConfirmation.php new file mode 100644 index 0000000000..eda9f7566e --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/UserInput/AskConfirmation.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\UserInput; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\EventSubscriber; +use SebastianFeldmann\Git\Repository; + +/** + * Debug hook to test hook triggering that fails the hook execution + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.20.1 + */ +class AskConfirmation implements Action, EventSubscriber +{ + /** + * Default question to ask the user + * + * @var string + */ + private static string $defaultMessage = 'Do you want to continue? [yes|no]'; + + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + // this action is just registering some event handler, so nothing to see here + } + + /** + * Returns a list of event handlers + * + * @param \CaptainHook\App\Config\Action $action + * @return array> + * @throws \Exception + */ + public static function getEventHandlers(Config\Action $action): array + { + $msg = $action->getOptions()->get('message', self::$defaultMessage); + $default = (bool) $action->getOptions()->get('default', false); + return [ + 'onHookSuccess' => [new EventHandler\AskConfirmation($msg, $default)] + ]; + } +} diff --git a/lib/captainhook/captainhook/src/Hook/UserInput/EventHandler/AskConfirmation.php b/lib/captainhook/captainhook/src/Hook/UserInput/EventHandler/AskConfirmation.php new file mode 100644 index 0000000000..8d0a55d4de --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/UserInput/EventHandler/AskConfirmation.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook\UserInput\EventHandler; + +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Event; +use CaptainHook\App\Event\Handler; +use CaptainHook\App\Exception\ActionFailed; + +/** + * Writes to commit message cache file to load it for a later commit + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +class AskConfirmation implements Handler +{ + /** + * Question to ask + * + * @var string + */ + private string $question; + + /** + * No input ok or not + * + * @var bool + */ + private bool $default; + + /** + * @param string $question + * @param bool $default + */ + public function __construct(string $question, bool $default = false) + { + $this->question = $question; + $this->default = $default; + } + + /** + * Writes the commit message to a cache file to reuse it for the next commit + * + * @param \CaptainHook\App\Event $event + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + public function handle(Event $event): void + { + if (!IOUtil::answerToBool($event->io()->ask(PHP_EOL . $this->question . ' ', $this->default ? 'y' : 'n'))) { + throw new ActionFailed('no confirmation, abort!'); + } + } +} diff --git a/lib/captainhook/captainhook/src/Hook/Util.php b/lib/captainhook/captainhook/src/Hook/Util.php new file mode 100644 index 0000000000..6c4f2dae15 --- /dev/null +++ b/lib/captainhook/captainhook/src/Hook/Util.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Hook; + +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hooks; +use RuntimeException; + +/** + * Class Util + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +final class Util +{ + /** + * Checks if a hook name is valid + * + * @param string $hook + * @return bool + */ + public static function isValid(string $hook): bool + { + return isset(Hooks::getValidHooks()[$hook]); + } + + /** + * Answers if a hook is installable + * + * Only native hooks are installable, virtual hooks used by the Cap'n should not be installed. + * + * @param string $hook + * @return bool + */ + public static function isInstallable(string $hook): bool + { + return isset(Hooks::nativeHooks()[$hook]); + } + + /** + * Returns list of valid hooks + * + * @return array + */ + public static function getValidHooks(): array + { + return Hooks::getValidHooks(); + } + + /** + * Returns hooks command class + * + * @param string $hook + * @return string + */ + public static function getHookCommand(string $hook): string + { + if (!self::isValid($hook)) { + throw new RuntimeException(sprintf('Hook \'%s\' is not supported', $hook)); + } + return Hooks::getValidHooks()[$hook]; + } + + /** + * Get a list of all supported hooks + * + * @return array + */ + public static function getHooks(): array + { + return array_keys(Hooks::getValidHooks()); + } + + /** + * Checks if a given hook was executed + * + * @param \CaptainHook\App\Console\IO $io + * @param string $hook + * @return bool + */ + public static function isRunningHook(IO $io, string $hook): bool + { + return str_contains($io->getArgument(Hooks::ARG_COMMAND), $hook); + } + + /** + * Detects the previous head commit hash + * + * @param \CaptainHook\App\Console\IO $io + * @return string + */ + public static function findPreviousHead(IO $io): string + { + // Check if a list of rewritten commits is supplied via stdIn. + // This happens if the 'post-rewrite' hook is triggered. + // The stdIn is formatted like this: + // + // old-hash new-hash extra-info + // old-hash new-hash extra-info + // ... + $stdIn = $io->getStandardInput(); + if (!empty($stdIn)) { + $info = explode(' ', $stdIn[0]); + // If we find a rewritten commit, we return the first commit before the rewritten one. + // If we do not find any rewritten commits (awkward) we use the last ref-log position. + return isset($info[1]) ? trim($info[1]) . '^' : 'HEAD@{1}'; + } + + return $io->getArgument(Hooks::ARG_PREVIOUS_HEAD, 'HEAD@{1}'); + } +} diff --git a/lib/captainhook/captainhook/src/Hooks.php b/lib/captainhook/captainhook/src/Hooks.php new file mode 100644 index 0000000000..21ac4b64bd --- /dev/null +++ b/lib/captainhook/captainhook/src/Hooks.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App; + +use RuntimeException; + +/** + * Class Hooks + * + * Defines the list of hooks that can be handled with captainhook and provides some name constants. + * + * @package CaptainHook + * @author Andrea Heigl + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.0.1 + */ +final class Hooks +{ + public const PRE_COMMIT = 'pre-commit'; + public const PRE_PUSH = 'pre-push'; + public const COMMIT_MSG = 'commit-msg'; + public const PREPARE_COMMIT_MSG = 'prepare-commit-msg'; + public const POST_COMMIT = 'post-commit'; + public const POST_MERGE = 'post-merge'; + public const POST_CHECKOUT = 'post-checkout'; + public const POST_REWRITE = 'post-rewrite'; + public const POST_CHANGE = 'post-change'; + + public const ARG_COMMAND = 'command'; + public const ARG_GIT_COMMAND = 'git-command'; + public const ARG_HASH = 'hash'; + public const ARG_MESSAGE_FILE = 'message-file'; + public const ARG_MODE = 'mode'; + public const ARG_NEW_HEAD = 'new-head'; + public const ARG_PREVIOUS_HEAD = 'previous-head'; + public const ARG_SQUASH = 'squash'; + public const ARG_TARGET = 'target'; + public const ARG_URL = 'url'; + + /** + * This defines which native hook trigger which virtual hook + * + * @var string[] + */ + private static array $virtualHookTriggers = [ + self::POST_CHECKOUT => self::POST_CHANGE, + self::POST_MERGE => self::POST_CHANGE, + self::POST_REWRITE => self::POST_CHANGE, + ]; + + /** + * Is it necessary to give the Captain access to user input + * + * @var array + */ + private static array $hooksReceivingStdInput = [ + self::PRE_PUSH => true, + self::POST_REWRITE => true, + ]; + + /** + * Returns the list of valid hooks + * + * @return array + */ + public static function getValidHooks(): array + { + return array_merge(self::nativeHooks(), self::virtualHooks()); + } + + /** + * Returns a list of all natively supported git hooks + * + * @return array + */ + public static function nativeHooks(): array + { + return [ + self::COMMIT_MSG => 'CommitMsg', + self::PRE_PUSH => 'PrePush', + self::PRE_COMMIT => 'PreCommit', + self::PREPARE_COMMIT_MSG => 'PrepareCommitMsg', + self::POST_COMMIT => 'PostCommit', + self::POST_MERGE => 'PostMerge', + self::POST_CHECKOUT => 'PostCheckout', + self::POST_REWRITE => 'PostRewrite' + ]; + } + + /** + * Return a list of all artificial CaptainHook hooks (virtual hooks) + * + * @return array + */ + public static function virtualHooks(): array + { + return [ + self::POST_CHANGE => 'PostChange' + ]; + } + + /** + * Returns a list of all native hooks triggered by a given virtual hook + * + * @return array + */ + public static function getNativeHooksForVirtualHook(string $virtualHook): array + { + return array_keys( + array_filter( + self::$virtualHookTriggers, + function ($e) use ($virtualHook) { + return $e === $virtualHook; + } + ) + ); + } + + /** + * Returns the argument placeholders for a given hook + * + * @param string $hook + * @return string + */ + public static function getOriginalHookArguments(string $hook): string + { + static $arguments = [ + Hooks::COMMIT_MSG => ' {$FILE}', + Hooks::POST_MERGE => ' {$SQUASH}', + Hooks::PRE_COMMIT => '', + Hooks::POST_COMMIT => '', + Hooks::PRE_PUSH => ' {$TARGET} {$URL}', + Hooks::PREPARE_COMMIT_MSG => ' {$FILE} {$MODE} {$HASH}', + Hooks::POST_CHECKOUT => ' {$PREVIOUSHEAD} {$NEWHEAD} {$MODE}', + Hooks::POST_REWRITE => ' {$GIT-COMMAND}', + ]; + + return $arguments[$hook]; + } + + /** + * Does a given hook allow for user input to be used + * + * @param string $hook + * @return bool + */ + public static function receivesStdIn(string $hook): bool + { + return self::$hooksReceivingStdInput[$hook] ?? false; + } + + /** + * Tell if the given hook should trigger a virtual hook + * + * @param string $hook + * @return bool + */ + public static function triggersVirtualHook(string $hook): bool + { + return isset(self::$virtualHookTriggers[$hook]); + } + + /** + * Return the virtual hook name that should be triggered by given hook + * + * @param string $hook + * @return string + */ + public static function getVirtualHook(string $hook): string + { + if (!self::triggersVirtualHook($hook)) { + throw new RuntimeException('no virtual hooks for ' . $hook); + } + return self::$virtualHookTriggers[$hook]; + } +} diff --git a/lib/captainhook/captainhook/src/Plugin/CaptainHook.php b/lib/captainhook/captainhook/src/Plugin/CaptainHook.php new file mode 100644 index 0000000000..aeba3048d2 --- /dev/null +++ b/lib/captainhook/captainhook/src/Plugin/CaptainHook.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Plugin; + +/** + * CaptainHook plugin interface + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.9.0. + */ +interface CaptainHook +{ +} diff --git a/lib/captainhook/captainhook/src/Plugin/Hook.php b/lib/captainhook/captainhook/src/Plugin/Hook.php new file mode 100644 index 0000000000..a40ae78304 --- /dev/null +++ b/lib/captainhook/captainhook/src/Plugin/Hook.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Plugin; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Runner\Hook as RunnerHook; +use SebastianFeldmann\Git\Repository; + +/** + * Runner plugin interface + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.9.0. + */ +interface Hook extends CaptainHook +{ + /** + * Configure the runner plugin. + * + * @param Config $config + * @param IO $io + * @param Repository $repository + * @param Config\Plugin $plugin + * @return void + */ + public function configure(Config $config, IO $io, Repository $repository, Config\Plugin $plugin): void; + + /** + * Execute before all actions + * + * @param RunnerHook $hook + * @return void + */ + public function beforeHook(RunnerHook $hook): void; + + /** + * Execute before each action + * + * @param RunnerHook $hook + * @param Config\Action $action + * @return void + */ + public function beforeAction(RunnerHook $hook, Config\Action $action): void; + + /** + * Execute after each action + * + * @param RunnerHook $hook + * @param Config\Action $action + * @return void + */ + public function afterAction(RunnerHook $hook, Config\Action $action): void; + + /** + * Execute after all actions + * + * @param RunnerHook $hook + * @return void + */ + public function afterHook(RunnerHook $hook): void; +} diff --git a/lib/captainhook/captainhook/src/Plugin/Hook/Base.php b/lib/captainhook/captainhook/src/Plugin/Hook/Base.php new file mode 100644 index 0000000000..40299da495 --- /dev/null +++ b/lib/captainhook/captainhook/src/Plugin/Hook/Base.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Plugin\Hook; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Plugin; +use SebastianFeldmann\Git\Repository; + +/** + * Base runner plugin abstract class + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.9.0. + */ +abstract class Base implements Plugin\Hook +{ + /** + * @var Config + */ + protected $config; + + /** + * @var IO + */ + protected $io; + + /** + * @var Repository + */ + protected $repository; + + /** + * @var Config\Plugin + */ + protected $plugin; + + public function configure(Config $config, IO $io, Repository $repository, Config\Plugin $plugin): void + { + $this->config = $config; + $this->io = $io; + $this->repository = $repository; + $this->plugin = $plugin; + } +} diff --git a/lib/captainhook/captainhook/src/Plugin/Hook/PreserveWorkingTree.php b/lib/captainhook/captainhook/src/Plugin/Hook/PreserveWorkingTree.php new file mode 100644 index 0000000000..edaf410fc9 --- /dev/null +++ b/lib/captainhook/captainhook/src/Plugin/Hook/PreserveWorkingTree.php @@ -0,0 +1,253 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Plugin\Hook; + +use CaptainHook\App\Config; +use CaptainHook\App\Hooks; +use CaptainHook\App\Plugin; +use CaptainHook\App\Runner; +use SebastianFeldmann\Git\Status\Path; +use Symfony\Component\Filesystem\Filesystem; + +/** + * PreserveWorkingTree runner plugin + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.9.0. + */ +class PreserveWorkingTree extends Base implements Plugin\Hook +{ + /** + * The name of the environment variable used to indicate the post-checkout + * hook should be skipped, to avoid recursion. + */ + public const SKIP_POST_CHECKOUT_VAR = '_CH_PLUGIN_PRESERVE_WORKING_TREE_SKIP_POST_CHECKOUT'; + + /** + * Files marked in the index as "intent to add." + * + * @var Path[] + */ + private $intentToAddFiles = []; + + /** + * A file where unstaged changes are stored as a patch. + * + * @var string|null + */ + private $unstagedPatchFile = null; + + /** + * @var Filesystem + */ + private $filesystem; + + /** + * PreserveWorkingTree constructor. + * + * @param Filesystem|null $filesystem + */ + public function __construct(?Filesystem $filesystem = null) + { + $this->filesystem = $filesystem ?? new Filesystem(); + } + + public function beforeHook(Runner\Hook $hook): void + { + $this->clearIntentToAddFiles(); + $this->clearUnstagedChanges(); + + if ($hook->getName() === Hooks::POST_CHECKOUT) { + // If this environment variable is set and is `true`, then we want + // to skip all actions configured by the post-checkout hook. + $hook->shouldSkipActions(filter_var( + getenv(self::SKIP_POST_CHECKOUT_VAR), + FILTER_VALIDATE_BOOLEAN + )); + } + } + + public function beforeAction(Runner\Hook $hook, Config\Action $action): void + { + // Do nothing. + } + + public function afterAction(Runner\Hook $hook, Config\Action $action): void + { + // Do nothing. + } + + public function afterHook(Runner\Hook $hook): void + { + $this->restoreUnstagedChanges(); + $this->restoreIntentToAddFiles(); + } + + /** + * Find whether there are any files marked as intent-to-add, cache them, and + * remove them from the index. + * + * @return void + */ + private function clearIntentToAddFiles(): void + { + $status = $this->repository->getStatusOperator()->getWorkingTreeStatus(); + + // Make sure we don't have something already set. + $this->intentToAddFiles = []; + + foreach ($status as $path) { + if ($path->isAddedInWorkingTree() === true) { + $this->intentToAddFiles[] = $path; + } + } + + if (count($this->intentToAddFiles) === 0) { + return; + } + + $this->io->write('Unstaged intent-to-add files detected.'); + + $this->repository->getIndexOperator()->removeFiles( + array_map(function (Path $path): string { + return $path->getPath(); + }, $this->intentToAddFiles), + false, + true + ); + } + + /** + * If we cached and removed from the index any files that were marked as + * intent-to-add, restore them to the index. + * + * @return void + */ + private function restoreIntentToAddFiles(): void + { + if (count($this->intentToAddFiles) === 0) { + return; + } + + $this->repository->getIndexOperator()->recordIntentToAddFiles( + array_map(function (Path $path): string { + return $path->getPath(); + }, $this->intentToAddFiles) + ); + + $this->io->write('Restored intent-to-add files.'); + + $this->intentToAddFiles = []; + } + + /** + * Find whether we have any unstaged changes in the working tree, cache them, + * and reset the working tree so we can continue processing the hook. + * + * @return void + * @throws \Exception + */ + private function clearUnstagedChanges(): void + { + $unstagedChanges = $this->repository->getDiffOperator()->getUnstagedPatch(); + + // Make sure we don't already have something set. + $this->unstagedPatchFile = null; + + if ($unstagedChanges === null) { + return; + } + + $patchFile = sys_get_temp_dir() + . '/CaptainHook/patches/' + . time() . '-' . bin2hex(random_bytes(4)) + . '.patch'; + + $this->filesystem->dumpFile($patchFile, $unstagedChanges); + + $this->unstagedPatchFile = $patchFile; + $this->restoreWorkingTree(); + } + + /** + * If we have cached unstaged changes, restore them to the working tree. + * + * @return void + */ + private function restoreUnstagedChanges(): void + { + if ($this->unstagedPatchFile === null) { + return; + } + + if (!$this->applyPatch($this->unstagedPatchFile)) { + $this->io->writeError([ + 'Stashed changes conflicted with hook auto-fixes...', + 'Rolling back fixes...', + ]); + + $this->restoreWorkingTree(); + + // At this point, the working tree should be pristine, so the + // patch should cleanly apply. + $this->applyPatch((string)$this->unstagedPatchFile); + } + + $this->io->write("Restored changes from {$this->unstagedPatchFile}."); + + $this->unstagedPatchFile = null; + } + + /** + * Apply a patch file to the working tree. + * + * We'll try twice, the second time disabling Git's core.autocrlf + * setting, in case the local system has it turned on and it's causing + * problems when trying to apply the patch. + * + * @param string $patchFile + * @return bool + */ + private function applyPatch(string $patchFile): bool + { + $diff = $this->repository->getDiffOperator(); + + if (!$diff->applyPatches([$patchFile])) { + if (!$diff->applyPatches([$patchFile], true)) { + return false; + } + } + + return true; + } + + /** + * Restores the working tree by running `git checkout`, while also setting + * an environment variable to instruct CaptainHook not to run post-checkout + * actions. + * + * @return void + */ + private function restoreWorkingTree(): void + { + // Set environment variable that tells this plugin to skip all + // actions when running the post-checkout hook, to avoid recursion. + putenv(self::SKIP_POST_CHECKOUT_VAR . '=1'); + + $this->repository->getStatusOperator()->restoreWorkingTree(); + + // Unset the environment variable. + putenv(self::SKIP_POST_CHECKOUT_VAR); + } +} diff --git a/lib/captainhook/captainhook/src/Runner.php b/lib/captainhook/captainhook/src/Runner.php new file mode 100644 index 0000000000..13b79b30c3 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App; + +use CaptainHook\App\Console\IO; + +/** + * Class Runner + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class Runner +{ + /** + * @var \CaptainHook\App\Console\IO + */ + protected $io; + + /** + * @var \CaptainHook\App\Config + */ + protected $config; + + /** + * Installer constructor. + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + */ + public function __construct(IO $io, Config $config) + { + $this->io = $io; + $this->config = $config; + } + + /** + * Executes the Runner. + */ + abstract public function run(): void; +} diff --git a/lib/captainhook/captainhook/src/Runner/Action.php b/lib/captainhook/captainhook/src/Runner/Action.php new file mode 100644 index 0000000000..0eaf192906 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use SebastianFeldmann\Git\Repository; + +/** + * Interface Action + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.19.0 + */ +interface Action +{ + /** + * Executes the action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void; +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli.php b/lib/captainhook/captainhook/src/Runner/Action/Cli.php new file mode 100644 index 0000000000..b6a723bcfc --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Exception; +use CaptainHook\App\Runner\Action as ActionRunner; +use CaptainHook\App\Runner\Action\Cli\Command\Formatter; +use SebastianFeldmann\Cli\Processor\Symfony as Processor; +use SebastianFeldmann\Git\Repository; + +/** + * Class Cli + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + * @internal + */ +class Cli implements ActionRunner +{ + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $processor = new Processor(); + $cmdOriginal = $action->getAction(); + $cmdFormatted = $this->formatCommand($io, $config, $repository, $cmdOriginal); + + $io->write( + ' cmd: ' . $cmdFormatted, + true, + IO::VERBOSE + ); + + $result = $processor->run($cmdFormatted); + $output = ''; + + if (!empty($result->getStdOut())) { + $output .= PHP_EOL . $result->getStdOut(); + } + if (!empty($result->getStdErr())) { + $output .= PHP_EOL . $result->getStdErr(); + } + + if (!empty($output)) { + $io->write( + [' command output:', trim($output)], + true, + !$result->isSuccessful() ? IO::NORMAL : IO::VERBOSE + ); + } + + if (!$result->isSuccessful()) { + throw new Exception\ActionFailed( + 'failed to execute: ' . $cmdFormatted + ); + } + } + + /** + * Replace argument placeholder with their original values + * + * This replaces the hook argument and other placeholders + * - prepare-commit-msg => FILE, MODE, HASH + * - commit-msg => FILE + * - pre-push => TARGET, URL + * - pre-commit => - + * - post-checkout => PREVIOUSHEAD, NEWHEAD, MODE + * - post-merge => SQUASH + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + * @param string $command + * @return string + */ + protected function formatCommand(IO $io, Config $config, Repository $repository, string $command): string + { + $formatter = new Formatter($io, $config, $repository); + return $formatter->format($command); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Formatter.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Formatter.php new file mode 100644 index 0000000000..83a4893d94 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Formatter.php @@ -0,0 +1,258 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Action\Cli\Command\Placeholder\Arg; +use SebastianFeldmann\Git\Repository; + +/** + * Class Formatter + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +class Formatter +{ + /** + * Cache storage for computed placeholder values + * + * @var array + */ + private static array $cache = []; + + /** + * Input output handler + * + * @var \CaptainHook\App\Console\IO + */ + private IO $io; + + /** + * CaptainHook configuration + * + * @var \CaptainHook\App\Config + */ + private Config $config; + + /** + * List of available placeholders + * + * @var array + */ + private static array $placeholders = [ + 'arg' => Placeholder\Arg::class, + 'config' => Placeholder\Config::class, + 'env' => Placeholder\Env::class, + 'staged_files' => Placeholder\StagedFiles::class, + 'changed_files' => Placeholder\ChangedFiles::class, + 'branch_files' => Placeholder\BranchFiles::class, + 'stdin' => Placeholder\StdIn::class, + ]; + + /** + * Previously used placeholders to replace arguments + * + * @var array + */ + private static array $legacyPlaceHolder = [ + 'FILE' => Hooks::ARG_MESSAGE_FILE, + 'GITCOMMAND' => Hooks::ARG_GIT_COMMAND, + 'HASH' => Hooks::ARG_HASH, + 'MODE' => Hooks::ARG_MODE, + 'NEWHEAD' => Hooks::ARG_NEW_HEAD, + 'PREVIOUSHEAD' => Hooks::ARG_PREVIOUS_HEAD, + 'SQUASH' => Hooks::ARG_SQUASH, + 'TARGET' => Hooks::ARG_TARGET, + 'URL' => Hooks::ARG_URL, + ]; + + /** + * Git repository + * + * @var \SebastianFeldmann\Git\Repository + */ + private Repository $repository; + + /** + * Formatter constructor + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository) + { + $this->io = $io; + $this->config = $config; + $this->repository = $repository; + } + + /** + * Replaces all placeholders in a cli command + * + * @param string $command + * @return string + */ + public function format(string $command): string + { + // find all replacements {SOMETHING} + $placeholders = $this->findAllPlaceholders($command); + foreach ($placeholders as $placeholder) { + $command = str_replace('{$' . $placeholder . '}', $this->replace($placeholder), $command); + } + + return $command; + } + + /** + * Returns al list of all placeholders + * + * @param string $command + * @return array + */ + private function findAllPlaceholders(string $command): array + { + $placeholders = []; + $matches = []; + + if (preg_match_all('#{\$([a-z_]+(\|[a-z\-]+:.*)?)}#iU', $command, $matches)) { + foreach ($matches[1] as $match) { + $placeholders[] = $match; + } + } + + return $placeholders; + } + + /** + * Return a given placeholder value + * + * @param string $placeholder + * @return string + */ + private function replace(string $placeholder): string + { + // if the placeholder references an original hook argument set up the real placeholder + // {$FILE} => ARG|value-of:message-file + if (array_key_exists($placeholder, self::$legacyPlaceHolder)) { + $argument = self::$legacyPlaceHolder[$placeholder]; + $placeholder = 'ARG|value-of:' . Arg::toPlaceholder($argument); + } + return $this->computedPlaceholder($placeholder); + } + + /** + * Compute the placeholder value + * + * @param string $rawPlaceholder Placeholder syntax {$NAME[|OPTION:VALUE]...} + * @return string + */ + private function computedPlaceholder(string $rawPlaceholder): string + { + // to not compute the same placeholder multiple times + if (!$this->isCached($rawPlaceholder)) { + // extract placeholder name and options + $parts = explode('|', $rawPlaceholder); + $placeholder = strtolower($parts[0]); + $options = $this->parseOptions(array_slice($parts, 1)); + + if (!$this->isPlaceholderValid($placeholder)) { + return ''; + } + + $this->io->write(' Placeholder: ' . $placeholder . '', true, IO::VERBOSE); + $processor = $this->createPlaceholder($placeholder); + $this->cache($rawPlaceholder, $processor->replacement($options)); + } + return $this->cached($rawPlaceholder); + } + + /** + * Placeholder factory method + * + * @param string $placeholder + * @return \CaptainHook\App\Runner\Action\Cli\Command\Placeholder + */ + private function createPlaceholder(string $placeholder): Placeholder + { + /** @var class-string<\CaptainHook\App\Runner\Action\Cli\Command\Placeholder> $class */ + $class = self::$placeholders[$placeholder]; + return new $class($this->io, $this->config, $this->repository); + } + + /** + * Checks if a placeholder is available for computation + * + * @param string $placeholder + * @return bool + */ + private function isPlaceholderValid(string $placeholder): bool + { + return isset(self::$placeholders[$placeholder]); + } + + /** + * Parse options from ["name:'value'", "name:'value'"] to ["name" => "value", "name" => "value"] + * + * @param array $raw + * @return array + */ + private function parseOptions(array $raw): array + { + $options = []; + foreach ($raw as $rawOption) { + $matches = []; + if (preg_match('#^([a-z_\-]+):(.*)?$#i', $rawOption, $matches)) { + $options[strtolower($matches[1])] = $matches[2] ?? ''; + } + } + return $options; + } + + /** + * Check if a placeholder is cached already + * + * @param string $placeholder + * @return bool + */ + private static function isCached(string $placeholder): bool + { + return isset(self::$cache[$placeholder]); + } + + /** + * Cache a given placeholder value + * + * @param string $placeholder + * @param string $replacement + */ + private static function cache(string $placeholder, string $replacement): void + { + self::$cache[$placeholder] = $replacement; + } + + /** + * Return cached value for given placeholder + * + * @param string $placeholder + * @return string + */ + private static function cached(string $placeholder): string + { + return self::$cache[$placeholder] ?? ''; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder.php new file mode 100644 index 0000000000..bab1913cac --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use SebastianFeldmann\Git\Repository; + +/** + * Interface Placeholder + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +interface Placeholder +{ + /** + * Placeholder constructor + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository); + + /** + * Return the replacement value for this placeholder + * + * @param array $options + * @return string + */ + public function replacement(array $options): string; +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Arg.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Arg.php new file mode 100644 index 0000000000..b25fc53a49 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Arg.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +/** + * Class Arg + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.19.0 + */ +class Arg extends Foundation +{ + /** + * Return the requested command ARGUMENT or a given default, returns empty string by default + * + * @param array $options + * @return string + */ + public function replacement(array $options): string + { + $var = $options['value-of'] ?? '_'; + $default = $options['default'] ?? ''; + + return $this->io->getArgument(self::toArgument($var), $default); + } + + /** + * Converts an argument name to a placeholder string + * + * @param string $arg + * @return string + */ + public static function toPlaceholder(string $arg): string + { + return str_replace('-', '_', strtoupper($arg)); + } + + /** + * Converts a placeholder string to an argument name + * + * @param string $placeholder + * @return string + */ + public static function toArgument(string $placeholder): string + { + return str_replace('_', '-', strtolower($placeholder)); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/BranchFiles.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/BranchFiles.php new file mode 100644 index 0000000000..87d8c40afe --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/BranchFiles.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +use CaptainHook\App\Git; +use CaptainHook\App\Hook\FileList; + +/** + * Changed Files Placeholder + * + * This placeholder only works for pre-push, post-rewrite, post-checkout and post-merge actions. + * If it is used in a pre-push hook and multiple refs are pushed the placeholder will contain + * all changed files for all refs. + * + * Usage examples: + * - {$BRANCH_FILES|compare-to:main|separated-by:,} + * - {$BRANCH_FILES|in-dir:foo/bar} + * - {$BRANCH_FILES|of-type:php} + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.21.0 + */ +class BranchFiles extends Foundation +{ + /** + * @param array $options + * @return string + */ + public function replacement(array $options): string + { + $branch = $this->repository->getInfoOperator()->getCurrentBranch(); + $start = $options['compared-to'] ?? $this->repository->getLogOperator()->getBranchRevFromRefLog($branch); + + if (empty($start)) { + $this->io->write('could not find branch start'); + return ''; + } + $files = $this->repository->getDiffOperator()->getChangedFiles($start, $branch, ['A', 'C', 'M', 'R']); + + return implode(($options['separated-by'] ?? ' '), FileList::filter($files, $options)); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/ChangedFiles.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/ChangedFiles.php new file mode 100644 index 0000000000..a2111cf75c --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/ChangedFiles.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +use CaptainHook\App\Git; +use CaptainHook\App\Hook\FileList; + +/** + * Changed Files Placeholder + * + * This placeholder only works for pre-push, post-rewrite, post-checkout and post-merge actions. + * If it is used in a pre-push hook and multiple refs are pushed the placeholder will contain + * all changed files for all refs. + * + * Usage examples: + * - {$CHANGED_FILES|separated-by:,} + * - {$CHANGED_FILES|in-dir:foo/bar} + * - {$CHANGED_FILES|of-type:php} + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.15.3 + */ +class ChangedFiles extends Foundation +{ + /** + * @param array $options + * @return string + */ + public function replacement(array $options): string + { + $files = Git\ChangedFiles::getChangedFiles($this->io, $this->repository, ['A', 'C', 'M', 'R']); + return implode(($options['separated-by'] ?? ' '), FileList::filter($files, $options)); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Config.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Config.php new file mode 100644 index 0000000000..66fad5a8c6 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Config.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +/** + * Class Config + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.6.0 + */ +class Config extends Foundation +{ + /** + * Maps the config value names to actual methods that have to be called to retrieve the value + * + * @var array + */ + private $valueToMethod = [ + 'bootstrap' => 'getBootstrap', + 'git-directory' => 'getGitDirectory', + 'php-path' => 'getPhpPath', + ]; + + /** + * @param array $options + * @return string + */ + public function replacement(array $options): string + { + if (!isset($options['value-of'])) { + return ''; + } + + return $this->getConfigValueFor($options['value-of']); + } + + /** + * Returns the config value '' by default if value is unknown + * + * @param string $value + * @return string + */ + private function getConfigValueFor(string $value): string + { + if (strpos($value, 'custom>>') === 0) { + $key = substr($value, 8); + $custom = $this->config->getCustomSettings(); + return $custom[$key] ?? ''; + } + if (!isset($this->valueToMethod[$value])) { + return ''; + } + + $method = $this->valueToMethod[$value]; + return $this->config->$method(); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Env.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Env.php new file mode 100644 index 0000000000..8f35d55905 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Env.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +use CaptainHook\App\Runner\Util; + +/** + * Class Env + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.8.0 + */ +class Env extends Foundation +{ + /** + * Return the requested ENVIRONMENT variable or a given default, returns empty string by default + * + * @param array $options + * @return string + */ + public function replacement(array $options): string + { + if (!isset($options['value-of'])) { + return ''; + } + + $var = $options['value-of']; + $default = $options['default'] ?? ''; + + return Util::getEnv($var, $default); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Foundation.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Foundation.php new file mode 100644 index 0000000000..ad19b36a6e --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Foundation.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Runner\Action\Cli\Command\Placeholder as PlaceholderInterface; +use SebastianFeldmann\Git\Repository; + +/** + * Class Foundation + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.6.0 + */ +abstract class Foundation implements PlaceholderInterface +{ + /** + * Input Output handler + * + * @var \CaptainHook\App\Console\IO + */ + protected IO $io; + + /** + * CaptainHook configuration + * + * @var \CaptainHook\App\Config + */ + protected Config $config; + + /** + * Git repository + * + * @var \SebastianFeldmann\Git\Repository + */ + protected Repository $repository; + + /** + * StagedFile constructor + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository) + { + $this->io = $io; + $this->config = $config; + $this->repository = $repository; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StagedFiles.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StagedFiles.php new file mode 100644 index 0000000000..b875fdf1fb --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StagedFiles.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +use CaptainHook\App\Hook\FileList; + +/** + * Class UpdatedFiles + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.0.0 + */ +class StagedFiles extends Foundation +{ + /** + * @param array $options + * @return string + */ + public function replacement(array $options): string + { + $filter = isset($options['diff-filter']) ? str_split($options['diff-filter']) : ['A', 'C', 'M', 'R']; + $files = isset($options['of-type']) + ? $this->repository->getIndexOperator()->getStagedFilesOfType($options['of-type'], $filter) + : $this->repository->getIndexOperator()->getStagedFiles($filter); + + $files = FileList::filterByDirectory($files, $options); + $files = FileList::replaceInAll($files, $options); + + return implode(($options['separated-by'] ?? ' '), $files); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StdIn.php b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StdIn.php new file mode 100644 index 0000000000..710e79bb91 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StdIn.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action\Cli\Command\Placeholder; + +/** + * Class StdIn + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.23.5 + */ +class StdIn extends Foundation +{ + /** + * Return the original hook stdIn (shell escaped) + * + * Returns at least '' + * + * @param array $options + * @return string + */ + public function replacement(array $options): string + { + return escapeshellarg(implode(PHP_EOL, $this->io->getStandardInput())); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/Log.php b/lib/captainhook/captainhook/src/Runner/Action/Log.php new file mode 100644 index 0000000000..7d791b8cc8 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/Log.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action; + +use CaptainHook\App\Config\Action; + +/** + * Class Log + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.19.0 + */ +class Log +{ + public const ACTION_SUCCEEDED = 0; + public const ACTION_SKIPPED = 1; + public const ACTION_DEACTIVATED = 2; + public const ACTION_FAILED = 4; + + /** + * @var \CaptainHook\App\Config\Action + */ + private Action $action; + + /** + * Was the action successful + * + * @var int + */ + private int $status; + + /** + * @var array<\CaptainHook\App\Console\IO\Message> + */ + private array $log = []; + + /** + * @param \CaptainHook\App\Config\Action $action + * @param int $status + * @param array<\CaptainHook\App\Console\IO\Message> $log + */ + public function __construct(Action $action, int $status, array $log) + { + $this->action = $action; + $this->status = $status; + $this->log = $log; + } + + /** + * Returns the action name + * + * @return string + */ + public function name(): string + { + return $this->action->getLabel(); + } + + /** + * Returns the action status + * + * @return int + */ + public function status(): int + { + return $this->status; + } + + /** + * Returns the list of messages + * + * @return array<\CaptainHook\App\Console\IO\Message> + */ + public function messages(): array + { + return $this->log; + } + + /** + * Check if the log has collected a message that should be displayed at a given verbosity + * + * @param int $verbosity + * @return bool + */ + public function hasMessageForVerbosity(int $verbosity): bool + { + foreach ($this->log as $msg) { + if ($msg->verbosity() <= $verbosity) { + return true; + } + } + return false; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Action/PHP.php b/lib/captainhook/captainhook/src/Runner/Action/PHP.php new file mode 100644 index 0000000000..138e001182 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Action/PHP.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Action; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Event\Dispatcher; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Action; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\EventSubscriber; +use CaptainHook\App\Runner\Action as ActionRunner; +use Error; +use Exception; +use RuntimeException; +use SebastianFeldmann\Git\Repository; + +/** + * Class PHP + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + * @internal + */ +class PHP implements ActionRunner +{ + /** + * Name of the currently executed hook + * + * @var string + */ + private $hook; + + /** + * + * @var \CaptainHook\App\Event\Dispatcher + */ + private $dispatcher; + + /** + * PHP constructor. + * + * @param string $hook Name of the currently executed hook + */ + public function __construct(string $hook, Dispatcher $dispatcher) + { + $this->hook = $hook; + $this->dispatcher = $dispatcher; + } + + /** + * Execute the configured action + * + * @param \CaptainHook\App\Config $config + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void + { + $class = $action->getAction(); + + try { + // if the configured action is a static php method display the captured output and exit + if ($this->isStaticMethodCall($class)) { + $io->write($this->executeStatic($class)); + return; + } + + // if not static it has to be an 'Action' so let's instantiate + $exe = $this->createAction($class); + // check for any given restrictions + if (!$this->isApplicable($exe)) { + $io->write('Action skipped due to hook constraint', true, IO::VERBOSE); + return; + } + + // make sure to collect all event handlers before executing the action + if ($exe instanceof EventSubscriber) { + $this->dispatcher->subscribeHandlers($class::getEventHandlers($action)); + } + + // no restrictions run it! + $exe->execute($config, $io, $repository, $action); + } catch (ActionFailed $e) { + throw $e; + } catch (Exception $e) { + throw new ActionFailed( + 'Execution failed: ' . PHP_EOL . + $e->getMessage() . ' in ' . $e->getFile() . ' line ' . $e->getLine() + ); + } catch (Error $e) { + throw new ActionFailed('PHP Error:' . $e->getMessage() . ' in ' . $e->getFile() . ' line ' . $e->getLine()); + } + } + + /** + * Execute static method call and return its output + * + * @param string $class + * @return string + */ + private function executeStatic(string $class): string + { + [$class, $method] = explode('::', $class); + if (!class_exists($class)) { + throw new RuntimeException('could not find class: ' . $class); + } + if (!method_exists($class, $method)) { + throw new RuntimeException('could not find method in class: ' . $method); + } + ob_start(); + $class::$method(); + return (string)ob_get_clean(); + } + + /** + * Create an action instance + * + * @param string $class + * @return \CaptainHook\App\Hook\Action + * @throws \CaptainHook\App\Exception\ActionFailed + */ + private function createAction(string $class): Action + { + $action = new $class(); + if (!$action instanceof Action) { + throw new ActionFailed( + 'PHP class ' . $class . ' has to implement the \'Action\' interface' + ); + } + return $action; + } + + /** + * Is this a static method call + * + * @param string $class + * @return bool + */ + private function isStaticMethodCall(string $class): bool + { + return (bool)preg_match('#^\\\\.+::.+$#i', $class); + } + + /** + * Make sure the action can be used during this hook + * + * @param \CaptainHook\App\Hook\Action $action + * @return bool + */ + private function isApplicable(Action $action) + { + if ($action instanceof Constrained) { + /** @var \CaptainHook\App\Hook\Constrained $action */ + return $action->getRestriction()->isApplicableFor($this->hook); + } + return true; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Bootstrap/Util.php b/lib/captainhook/captainhook/src/Runner/Bootstrap/Util.php new file mode 100644 index 0000000000..5c556dd853 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Bootstrap/Util.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Bootstrap; + +use CaptainHook\App\Config; +use RuntimeException; + +/** + * Bootstrap Util + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.23.3 + */ +class Util +{ + /** + * Return the bootstrap file to load (can be empty) + * + * @param bool $isPhar + * @param \CaptainHook\App\Config $config + * @return string + */ + public static function validateBootstrapPath(bool $isPhar, Config $config): string + { + $bootstrapFile = dirname($config->getPath()) . '/' . $config->getBootstrap(); + if (!file_exists($bootstrapFile)) { + // since the phar has its own autoloader we don't need to do anything + // if the bootstrap file is not actively set + if ($isPhar && empty($config->getBootstrap(''))) { + return ''; + } + throw new RuntimeException('bootstrap file not found'); + } + return $bootstrapFile; + } + + /** + * Returns the bootstrap command option (can be empty) + * + * @param bool $isPhar + * @param \CaptainHook\App\Config $config + * @return string + */ + public static function bootstrapCmdOption(bool $isPhar, Config $config): string + { + // nothing to load => no option + if ($isPhar && empty($config->getBootstrap(''))) { + return ''; + } + return ' --bootstrap=' . $config->getBootstrap(); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Condition.php b/lib/captainhook/captainhook/src/Runner/Condition.php new file mode 100644 index 0000000000..3363db895a --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Condition.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Hook\Condition as ConditionInterface; +use CaptainHook\App\Hook\Condition\Cli; +use CaptainHook\App\Hook\Constrained; +use SebastianFeldmann\Cli\Processor\ProcOpen as Processor; +use SebastianFeldmann\Git\Repository; +use RuntimeException; + +/** + * Condition Runner + * + * Executes a condition of an action by creating a `Condition` object from a condition configuration. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + * @internal + */ +class Condition +{ + /** + * @var \CaptainHook\App\Console\IO + */ + private IO $io; + + /** + * @var \SebastianFeldmann\Git\Repository + */ + private Repository $repository; + + /** + * @var \CaptainHook\App\Config + */ + private Config $config; + + /** + * Currently executed hook + * + * @var string + */ + private string $hook; + + /** + * Condition constructor. + * + * @param \CaptainHook\App\Console\IO $io + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Config $config + * @param string $hook + */ + public function __construct(IO $io, Repository $repository, Config $config, string $hook) + { + $this->io = $io; + $this->repository = $repository; + $this->config = $config; + $this->hook = $hook; + } + + /** + * Creates the configured condition and evaluates it + * + * @param \CaptainHook\App\Config\Condition $config + * @return bool + */ + public function doesConditionApply(Config\Condition $config): bool + { + $condition = $this->createCondition($config); + // check all given restrictions + if (!$this->isApplicable($condition)) { + $this->io->write('Condition skipped due to hook constraint', true, IO::VERBOSE); + return true; + } + return $condition->isTrue($this->io, $this->repository); + } + + /** + * Return the configured condition + * + * In case of a cli condition it returns an special condition class that deals with + * the binary execution with implementing the same interface. + * + * @param \CaptainHook\App\Config\Condition $config + * @return \CaptainHook\App\Hook\Condition + * @throws \RuntimeException + */ + private function createCondition(Config\Condition $config): ConditionInterface + { + if ($this->isLogicCondition($config)) { + return $this->createLogicCondition($config); + } + + if (Util::getExecType($config->getExec()) === 'cli') { + return new Cli(new Processor(), $config->getExec()); + } + + /** @var class-string<\CaptainHook\App\Hook\Condition> $class */ + $class = $config->getExec(); + if (!class_exists($class)) { + throw new RuntimeException('could not find condition class: ' . $class); + } + $condition = new $class(...$config->getArgs()); + if ($condition instanceof ConditionInterface\ConfigDependant) { + $condition->setConfig($this->config); + } + return $condition; + } + + /** + * Create a logic condition with configures sub conditions + * + * @param \CaptainHook\App\Config\Condition $config + * @return \CaptainHook\App\Hook\Condition + */ + private function createLogicCondition(Config\Condition $config): ConditionInterface + { + $class = '\\CaptainHook\\App\\Hook\\Condition\\Logic\\Logic' . ucfirst(strtolower($config->getExec())); + $conditions = []; + foreach ($config->getArgs() as $conf) { + $condition = $this->createCondition(new Config\Condition($conf['exec'], $conf['args'] ?? [])); + if (!$this->isApplicable($condition)) { + $this->io->write('Condition skipped due to hook constraint', true, IO::VERBOSE); + continue; + } + $conditions[] = $condition; + } + return $class::fromConditionsArray($conditions); + } + + /** + * Make sure the condition can be used during this hook + * + * @param \CaptainHook\App\Hook\Condition $condition + * @return bool + */ + private function isApplicable(ConditionInterface $condition): bool + { + if ($condition instanceof Constrained) { + return $condition->getRestriction()->isApplicableFor($this->hook); + } + return true; + } + + /** + * Is the condition a logic 'AND' or 'OR' condition + * + * @param \CaptainHook\App\Config\Condition $config + * @return bool + */ + private function isLogicCondition(Config\Condition $config): bool + { + return in_array(strtolower($config->getExec()), ['and', 'or']); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Change.php b/lib/captainhook/captainhook/src/Runner/Config/Change.php new file mode 100644 index 0000000000..5075b2375b --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Change.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config\Change; + +use CaptainHook\App\Config; +use CaptainHook\App\Runner\Config\Setup\Advanced; + +/** + * Class AddAction + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class AddAction extends Hook +{ + /** + * Apply changes to the given config + * + * @param \CaptainHook\App\Config $config + * @return void + * @throws \Exception + */ + public function applyTo(Config $config): void + { + $hookConfig = $config->getHookConfig($this->hookToChange); + $setup = new Advanced($this->io); + $actionConfig = $setup->getActionConfig(); + + $hookConfig->addAction($actionConfig); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Change/DisableHook.php b/lib/captainhook/captainhook/src/Runner/Config/Change/DisableHook.php new file mode 100644 index 0000000000..8c1b845e9e --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Change/DisableHook.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config\Change; + +use CaptainHook\App\Config; + +/** + * Class AddAction + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class DisableHook extends Hook +{ + /** + * Apply changes to the given config + * + * @param \CaptainHook\App\Config $config + * @return void + * @throws \Exception + */ + public function applyTo(Config $config): void + { + $config->getHookConfig($this->hookToChange)->setEnabled(false); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Change/EnableHook.php b/lib/captainhook/captainhook/src/Runner/Config/Change/EnableHook.php new file mode 100644 index 0000000000..d93145b8f1 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Change/EnableHook.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config\Change; + +use CaptainHook\App\Config; + +/** + * Class AddAction + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class EnableHook extends Hook +{ + /** + * Apply changes to the given config + * + * @param \CaptainHook\App\Config $config + * @return void + * @throws \Exception + */ + public function applyTo(Config $config): void + { + $config->getHookConfig($this->hookToChange)->setEnabled(true); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Change/Hook.php b/lib/captainhook/captainhook/src/Runner/Config/Change/Hook.php new file mode 100644 index 0000000000..41fdbea580 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Change/Hook.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config\Change; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Runner\Config\Change; + +/** + * Class AddAction + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +abstract class Hook implements Change +{ + /** + * @var \CaptainHook\App\Console\IO + */ + protected $io; + + /** + * Name of the hook to add the action to + * + * @var string + */ + protected $hookToChange; + + /** + * AddAction constructor + * + * @param \CaptainHook\App\Console\IO $io + * @param string $hookToChange + */ + public function __construct(IO $io, string $hookToChange) + { + $this->io = $io; + $this->hookToChange = $hookToChange; + } + + /** + * Apply changes to the given config + * + * @param \CaptainHook\App\Config $config + * @return void + * @throws \Exception + */ + abstract public function applyTo(Config $config): void; +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Creator.php b/lib/captainhook/captainhook/src/Runner/Config/Creator.php new file mode 100644 index 0000000000..167f3d7262 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Creator.php @@ -0,0 +1,185 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config; + +use CaptainHook\App\Config; +use CaptainHook\App\Runner; +use RuntimeException; + +/** + * Class Configurator + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Creator extends Runner +{ + /** + * Force mode + * + * @var bool + */ + private bool $force = false; + + /** + * Extend existing config or create new one + * + * @var string + */ + private string $mode = 'create'; + + /** + * Use express setup mode + * + * @var bool + */ + private bool $advanced = false; + + /** + * Path to the currently executed 'binary' + * + * @var string + */ + protected string $executable = ''; + + /** + * Execute the configurator + * + * @return void + */ + public function run(): void + { + $config = $this->getConfigToManipulate(); + $setup = $this->getHookSetup(); + + $setup->configureHooks($config); + Config\Util::writeToDisk($config); + + $this->io->write( + [ + 'Configuration created successfully', + 'Run \'' . $this->getExecutable() . ' install\' to activate your hook configuration', + ] + ); + } + + /** + * Force mode setter + * + * @param bool $force + * @return \CaptainHook\App\Runner\Config\Creator + */ + public function force(bool $force): Creator + { + $this->force = $force; + return $this; + } + + /** + * Set configuration mode + * + * @param bool $extend + * @return \CaptainHook\App\Runner\Config\Creator + */ + public function extend(bool $extend): Creator + { + $this->mode = $extend ? 'extend' : 'create'; + return $this; + } + + /** + * Set configuration speed + * + * @param bool $advanced + * @return \CaptainHook\App\Runner\Config\Creator + */ + public function advanced(bool $advanced): Creator + { + $this->advanced = $advanced; + return $this; + } + + /** + * Set the currently executed 'binary' + * + * @param string $executable + * @return \CaptainHook\App\Runner\Config\Creator + */ + public function setExecutable(string $executable): Creator + { + $this->executable = $executable; + return $this; + } + + /** + * Return config to handle + * + * @return \CaptainHook\App\Config + */ + public function getConfigToManipulate(): Config + { + if (!$this->isExtending()) { + // make sure the force option is set if the configuration file exists + $this->ensureForce(); + // create a blank configuration to overwrite the old one + return new Config($this->config->getPath()); + } + return $this->config; + } + + /** + * Return the setup handler to ask the user questions + * + * @return \CaptainHook\App\Runner\Config\Setup + */ + private function getHookSetup(): Setup + { + return $this->advanced + ? new Setup\Advanced($this->io) + : new Setup\Express($this->io); + } + + /** + * Should the config file be extended + * + * @return bool + */ + private function isExtending(): bool + { + return 'extend' === $this->mode; + } + + /** + * Make sure force mode is set if config file exists + * + * @return void + * @throws \RuntimeException + */ + private function ensureForce(): void + { + if ($this->config->isLoadedFromFile() && !$this->force) { + throw new RuntimeException('Configuration file exists, use -f to overwrite, or -e to extend'); + } + } + + /** + * Return path to currently executed 'binary' + * + * @return string + */ + private function getExecutable(): string + { + return !empty($this->executable) ? $this->executable : 'vendor/bin/captainhook'; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Editor.php b/lib/captainhook/captainhook/src/Runner/Config/Editor.php new file mode 100644 index 0000000000..53c2d0b902 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Editor.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config; + +use CaptainHook\App\Config; +use CaptainHook\App\Exception; +use CaptainHook\App\Hook\Util; +use CaptainHook\App\Runner; +use RuntimeException; + +/** + * Class Editor + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.2.0 + */ +class Editor extends Runner +{ + /** + * @var string + */ + private $hookToEdit; + + /** + * The name of the change that will be applied to the configuration + * + * @var string + */ + private $change; + + /** + * Set the hook that should be changed + * + * @param string $hook + * @return \CaptainHook\App\Runner\Config\Editor + * @throws \CaptainHook\App\Exception\InvalidHookName + */ + public function setHook(string $hook): Editor + { + if (!Util::isValid($hook)) { + throw new Exception\InvalidHookName('Invalid hook name \'' . $hook . '\''); + } + $this->hookToEdit = $hook; + return $this; + } + + /** + * Set the name of the change that should be performed on the configuration + * + * @param string $change + * @return \CaptainHook\App\Runner\Config\Editor + */ + public function setChange(string $change): Editor + { + $this->change = $change; + return $this; + } + + /** + * Executes the Runner + * + * @return void + * @throws \RuntimeException + */ + public function run(): void + { + if (!$this->config->isLoadedFromFile()) { + throw new RuntimeException('No configuration to edit'); + } + + $this->checkHook(); + $this->checkChange(); + + $change = $this->createChange(); + $change->applyTo($this->config); + Config\Util::writeToDisk($this->config); + + $this->io->write('Configuration successfully updated'); + } + + /** + * Create a config edit command + * + * @return \CaptainHook\App\Runner\Config\Change + * @throws \RuntimeException + */ + private function createChange(): Change + { + /** @var class-string<\CaptainHook\App\Runner\Config\Change> $changeClass */ + $changeClass = '\\CaptainHook\\App\\Runner\\Config\\Change\\' . $this->change; + if (!class_exists($changeClass)) { + throw new RuntimeException('Invalid change requested'); + } + + return new $changeClass($this->io, $this->hookToEdit); + } + + /** + * Makes sure a hook is selected + * + * @return void + * @throws \RuntimeException + */ + private function checkHook(): void + { + if (empty($this->hookToEdit)) { + throw new RuntimeException('No hook set'); + } + } + + /** + * Makes sure a command is set + * + * @return void + * @throws \RuntimeException + */ + private function checkChange(): void + { + if (empty($this->change)) { + throw new RuntimeException('No change set'); + } + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Reader.php b/lib/captainhook/captainhook/src/Runner/Config/Reader.php new file mode 100644 index 0000000000..fdce1966bc --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Reader.php @@ -0,0 +1,299 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config; + +use CaptainHook\App\Config; +use CaptainHook\App\Hook\Util as HookUtil; +use CaptainHook\App\Runner; +use CaptainHook\App\Runner\Hook\Arg; +use RuntimeException; + +/** + * Class Info + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.24.0 + */ +class Reader extends Runner\RepositoryAware +{ + /** + * Option values + */ + public const OPT_ACTIONS = 'actions'; + public const OPT_CONDITIONS = 'conditions'; + public const OPT_OPTIONS = 'options'; + + /** + * The hook to display + * + * @var array + */ + private array $hooks = []; + + /** + * @var array + */ + private array $options = []; + + /** + * Show more detailed information + * @var bool + */ + private bool $extensive = false; + + /** + * Limit uninstall to s specific hook + * + * @param string $hook + * @return static + * @throws \CaptainHook\App\Exception\InvalidHookName + */ + public function setHook(string $hook): self + { + $arg = new Arg( + $hook, + static function (string $hook): bool { + return !HookUtil::isValid($hook); + } + ); + $this->hooks = $arg->hooks(); + return $this; + } + + /** + * Set the display setting for a config section (actions, conditions, options) + * + * @param string $name + * @param bool $value + * @return $this + */ + public function display(string $name, bool $value): Reader + { + if ($value) { + $this->options[$name] = true; + } + return $this; + } + + /** + * Show more detailed information + * + * @param bool $value + * @return $this + */ + public function extensive(bool $value): Reader + { + $this->extensive = $value; + return $this; + } + + /** + * Executes the Runner + * + * @return void + * @throws \RuntimeException + */ + public function run(): void + { + if (!$this->config->isLoadedFromFile()) { + throw new RuntimeException('No configuration to read'); + } + foreach ($this->config->getHookConfigs() as $hookConfig) { + $this->displayHook($hookConfig); + } + } + + /** + * Display a hook configuration + * @param \CaptainHook\App\Config\Hook $config + * @return void + */ + private function displayHook(Config\Hook $config): void + { + if ($this->shouldHookBeDisplayed($config->getName())) { + $this->io->write('' . $config->getName() . '', !$this->extensive); + $this->displayExtended($config); + $this->displayActions($config); + } + } + + /** + * Display detailed information + * + * @param \CaptainHook\App\Config\Hook $config + * @return void + */ + private function displayExtended(Config\Hook $config): void + { + if ($this->extensive) { + $this->io->write( + ' ' . str_repeat('-', 52 - strlen($config->getName())) . + '--[enabled: ' . $this->yesOrNo($config->isEnabled()) . + ', installed: ' . $this->yesOrNo($this->repository->hookExists($config->getName())) . ']' + ); + } + } + + /** + * Display all actions + * + * @param \CaptainHook\App\Config\Hook $config + * @return void + */ + private function displayActions(Config\Hook $config): void + { + foreach ($config->getActions() as $action) { + $this->displayAction($action); + } + } + + /** + * Display a single Action + * + * @param \CaptainHook\App\Config\Action $action + * @return void + */ + private function displayAction(Config\Action $action): void + { + $this->io->write(' - ' . $action->getAction() . ''); + $this->displayOptions($action->getOptions()); + $this->displayConditions($action->getConditions()); + } + + /** + * Display all options + * + * @param \CaptainHook\App\Config\Options $options + * @return void + */ + private function displayOptions(Config\Options $options): void + { + if (empty($options->getAll())) { + return; + } + if ($this->show(self::OPT_OPTIONS)) { + $this->io->write(' Options:'); + foreach ($options->getAll() as $key => $value) { + $this->displayOption($key, $value); + } + } + } + + /** + * Display a singe option + * + * @param mixed $key + * @param mixed $value + * @param string $prefix + * @return void + */ + private function displayOption(mixed $key, mixed $value, string $prefix = ''): void + { + if (is_array($value)) { + $value = implode(', ', $value); + } + $this->io->write($prefix . ' - ' . $key . ': ' . $value); + } + + /** + * Display all conditions + * + * @param array<\CaptainHook\App\Config\Condition> $conditions + * @param string $prefix + * @return void + */ + private function displayConditions(array $conditions, string $prefix = ''): void + { + if (empty($conditions)) { + return; + } + if ($this->show(self::OPT_CONDITIONS)) { + if (empty($prefix)) { + $this->io->write($prefix . ' Conditions:'); + } + foreach ($conditions as $condition) { + $this->displayCondition($condition, $prefix); + } + } + } + + /** + * Display a single Condition + * + * @param \CaptainHook\App\Config\Condition $condition + * @param string $prefix + * @return void + */ + private function displayCondition(Config\Condition $condition, string $prefix = ''): void + { + $this->io->write($prefix . ' - ' . $condition->getExec()); + + if (in_array(strtoupper($condition->getExec()), ['OR', 'AND'])) { + $conditions = []; + foreach ($condition->getArgs() as $conf) { + $conditions[] = new Config\Condition($conf['exec'], $conf['args'] ?? []); + } + $this->displayConditions($conditions, $prefix . ' '); + return; + } + if ($this->show(self::OPT_OPTIONS)) { + if (empty($condition->getArgs())) { + return; + } + $this->io->write($prefix . ' Args:'); + foreach ($condition->getArgs() as $key => $value) { + $this->displayOption($key, $value, $prefix . ' '); + } + } + } + + /** + * Check if a specific config part should be shown + * + * @param string $option + * @return bool + */ + private function show(string $option): bool + { + if (empty($this->options)) { + return true; + } + return $this->options[$option] ?? false; + } + + /** + * Check if a hook should be displayed + * + * @param string $name + * @return bool + */ + private function shouldHookBeDisplayed(string $name): bool + { + if (empty($this->hooks)) { + return true; + } + return in_array($name, $this->hooks); + } + + /** + * Return yes or no emoji + * + * @param bool $bool + * @return string + */ + private function yesOrNo(bool $bool): string + { + return $bool ? '✅ ' : '❌ '; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Setup.php b/lib/captainhook/captainhook/src/Runner/Config/Setup.php new file mode 100644 index 0000000000..cdaab547c4 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Setup.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config; + +use CaptainHook\App\Config; + +/** + * Interface Setup + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 2.1.3 + */ +interface Setup +{ + /** + * Setup hook configurations by asking some questions + * + * @param \CaptainHook\App\Config $config + * @return void + */ + public function configureHooks(Config $config): void; +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Setup/Advanced.php b/lib/captainhook/captainhook/src/Runner/Config/Setup/Advanced.php new file mode 100644 index 0000000000..87423dc703 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Setup/Advanced.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config\Setup; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Hook\Util as HookUtil; +use CaptainHook\App\Runner\Util as RunnerUtil; +use CaptainHook\App\Runner\Config\Setup; + +/** + * Class Advanced + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 2.2.0 + */ +class Advanced extends Guided implements Setup +{ + /** + * Setup hook configurations by asking some questions + * + * @param \CaptainHook\App\Config $config + * @return void + * @throws \Exception + */ + public function configureHooks(Config $config): void + { + foreach (HookUtil::getHooks() as $hook) { + $this->configureHook($config->getHookConfig($hook), $hook); + } + } + + /** + * Configure a hook by asking some questions + * + * @param \CaptainHook\App\Config\Hook $config + * @param string $name + * @return void + * @throws \Exception + */ + public function configureHook(Config\Hook $config, string $name): void + { + $answer = $this->io->ask(' Enable \'' . $name . '\' hook? [y,n] ', 'n'); + $enable = IOUtil::answerToBool($answer); + + $config->setEnabled($enable); + + if ($enable) { + $addAction = $this->io->ask(' Add a validation action? [y,n] ', 'n'); + + while (IOUtil::answerToBool($addAction)) { + $config->addAction($this->getActionConfig()); + // add another action? + $addAction = $this->io->ask( + ' Add another validation action? [y,n] ', + 'n' + ); + } + } + } + + /** + * Setup a action config with user input + * + * @return \CaptainHook\App\Config\Action + * @throws \Exception + */ + public function getActionConfig(): Config\Action + { + $call = $this->io->ask(' PHP class or shell command to execute? '); + $options = $this->getActionOptions(RunnerUtil::getExecType($call)); + + return new Config\Action($call, $options); + } + + /** + * Ask the user for any action options + * + * @param string $type + * @return array + * @throws \Exception + */ + public function getActionOptions(string $type): array + { + return 'php' === $type ? $this->getPHPActionOptions() : []; + } + + /** + * Get the php action options + * + * @return array + * @throws \Exception + */ + protected function getPHPActionOptions(): array + { + $options = []; + $addOption = $this->io->ask(' Add a validator option? [y,n] ', 'n'); + while (IOUtil::answerToBool($addOption)) { + $options[] = $this->getPHPActionOption(); + // add another action? + $addOption = $this->io->ask(' Add another validator option? [y,n] ', 'n'); + } + return array_merge(...$options); + } + + /** + * Ask the user for a php action option + * + * @return array + * @throws \Exception + */ + protected function getPHPActionOption(): array + { + $result = []; + $answer = $this->io->askAndValidate( + ' Specify options key and value [key:value] ', + ['\\CaptainHook\\App\\Runner\\Config\\Setup\\Guided', 'isPHPActionOptionValid'], + 3, + null + ); + if (null !== $answer) { + list($key, $value) = explode(':', $answer); + $result = [$key => $value]; + } + return $result; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Setup/Express.php b/lib/captainhook/captainhook/src/Runner/Config/Setup/Express.php new file mode 100644 index 0000000000..4610b66fe2 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Setup/Express.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config\Setup; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Config\Setup; + +/** + * Class Express + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 2.2.0 + */ +class Express extends Guided implements Setup +{ + /** + * Setup hooks by asking some basic questions + * + * @param \CaptainHook\App\Config $config + * @throws \Exception + */ + public function configureHooks(Config $config): void + { + $msgHook = $config->getHookConfig(Hooks::COMMIT_MSG); + $preHook = $config->getHookConfig(Hooks::PRE_COMMIT); + $msgHook->setEnabled(true); + $preHook->setEnabled(true); + + $this->setupMessageHook($msgHook); + $this->setupPHPLintingHook($preHook); + $this->setupPHPUnitHook($preHook); + $this->setupPHPCodesnifferHook($preHook); + } + + /** + * Set up the commit message hook + * + * @param \CaptainHook\App\Config\Hook $config + * @return void + * @throws \Exception + */ + private function setupMessageHook(Config\Hook $config): void + { + $answer = $this->io->ask( + ' Do you want to validate your commit messages? [y,n] ', + 'n' + ); + + if (IOUtil::answerToBool($answer)) { + $call = '\\CaptainHook' . '\\App' . '\\Hook\\Message\\Action\\Beams'; + $options = ['subjectLength' => 50, 'bodyLineLength' => 72]; + $config->addAction(new Config\Action($call, $options)); + } + } + + /** + * Set up the linting hook + * + * @param \CaptainHook\App\Config\Hook $config + * @return void + * @throws \Exception + */ + private function setupPHPLintingHook(Config\Hook $config): void + { + $answer = $this->io->ask( + ' Do you want to check your files for syntax errors? [y,n] ', + 'n' + ); + + if (IOUtil::answerToBool($answer)) { + $call = '\\CaptainHook' . '\\App' . '\\Hook\\PHP\\Action\\Linting'; + $config->addAction(new Config\Action($call)); + } + } + + /** + * Setup the phpunit hook + * + * @param \CaptainHook\App\Config\Hook $config + * @return void + * @throws \Exception + */ + private function setupPHPUnitHook(Config\Hook $config): void + { + $answer = $this->io->ask( + ' Do you want to run phpunit before committing? [y,n] ', + 'n' + ); + + if (IOUtil::answerToBool($answer)) { + $call = $this->io->ask( + ' Enter the phpunit command you want to execute. [phpunit] ', + 'phpunit' + ); + $config->addAction(new Config\Action($call)); + } + } + + /** + * Setup the code sniffer hook + * + * @param \CaptainHook\App\Config\Hook $config + * @return void + * @throws \Exception + */ + private function setupPHPCodesnifferHook(Config\Hook $config): void + { + $answer = $this->io->ask( + ' Do you want to run phpcs before committing? [y,n] ', + 'n' + ); + + if (IOUtil::answerToBool($answer)) { + $call = $this->io->ask( + ' Enter the phpcs command you want to execute. ' + . '[phpcs --standard=psr2 src] ', + 'phpcs --standard=psr2 src' + ); + $config->addAction(new Config\Action($call)); + } + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Config/Setup/Guided.php b/lib/captainhook/captainhook/src/Runner/Config/Setup/Guided.php new file mode 100644 index 0000000000..154fc0e1d1 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Config/Setup/Guided.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Config\Setup; + +use CaptainHook\App\Console\IO; +use Exception; + +/** + * Class Guided + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 2.2.0 + */ +abstract class Guided +{ + /** + * @var \CaptainHook\App\Console\IO + */ + protected $io; + + /** + * Guided constructor + * + * @param \CaptainHook\App\Console\IO $io + */ + public function __construct(IO $io) + { + $this->io = $io; + } + + /** + * PHP action option validation + * + * @param string $option + * @return string + * @throws \Exception + */ + public static function isPHPActionOptionValid(string $option): string + { + if (count(explode(':', $option)) !== 2) { + throw new Exception('Invalid option, use "key:value"'); + } + return $option; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Files.php b/lib/captainhook/captainhook/src/Runner/Files.php new file mode 100644 index 0000000000..32899a303e --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Files.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Runner; + +use CaptainHook\App\Exception; +use CaptainHook\App\Hook\Util as HookUtil; +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook\Arg; +use RuntimeException; +use SebastianFeldmann\Camino\Check; + +/** + * Class HookMover + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.11.0 + */ +abstract class Files extends RepositoryAware +{ + /** + * Handle hooks brute force + * + * @var bool + */ + protected bool $force = false; + + /** + * Path where the existing hooks should be moved to + * + * @var string + */ + protected string $moveExistingTo = ''; + + /** + * Hook(s) that should be handled. + * + * @var array + */ + protected array $hooksToHandle; + + /** + * @param bool $force + * @return static + */ + public function setForce(bool $force): self + { + $this->force = $force; + return $this; + } + + /** + * Set the path where the current hooks should be moved to + * + * @param string $backup + * @return static + */ + public function setMoveExistingTo(string $backup): static + { + $this->moveExistingTo = $backup; + return $this; + } + + /** + * Limit uninstall to s specific hook + * + * @param string $hook + * @return static + * @throws \CaptainHook\App\Exception\InvalidHookName + */ + public function setHook(string $hook): self + { + $arg = new Arg( + $hook, + static function (string $hook): bool { + return !HookUtil::isInstallable($hook); + } + ); + + $this->hooksToHandle = $arg->hooks(); + return $this; + } + + /** + * Return list of hooks to handle + * + * [ + * string => bool + * HOOK_NAME => ASK_USER_TO_CONFIRM_INSTALL + * ] + * + * @return array + */ + protected function getHooksToHandle(): array + { + // if specific hooks are set, the user has actively chosen it, so don't ask for permission anymore + // if all hooks get installed ask for permission + return !empty($this->hooksToHandle) + ? array_map(fn($hook) => false, array_flip($this->hooksToHandle)) + : array_map(fn($hook) => true, Hooks::nativeHooks()); + } + + /** + * If a path to incorporate the existing hook is set we should incorporate existing hooks + * + * @return bool + */ + protected function shouldHookBeMoved(): bool + { + return !empty($this->moveExistingTo); + } + + /** + * Move the existing hook to the configured location + * + * @param string $hook + */ + protected function backupHook(string $hook): void + { + // no hook to move just exit + if (!$this->repository->hookExists($hook)) { + return; + } + + $hookFileOrig = $this->repository->getHooksDir() . DIRECTORY_SEPARATOR . $hook; + $hookCmd = rtrim($this->moveExistingTo, '/\\') . DIRECTORY_SEPARATOR . $hook; + $hookCmdArgs = $hookCmd . Hooks::getOriginalHookArguments($hook); + $hookFileTarget = !Check::isAbsolutePath($this->moveExistingTo) + ? dirname($this->config->getPath()) . DIRECTORY_SEPARATOR . $hookCmd + : $hookCmd; + + $this->moveExistingHook($hookFileOrig, $hookFileTarget); + + $this->io->write( + [ + ' Moved existing ' . $hook . ' hook to ' . $hookCmd, + ' Add \'' . $hookCmdArgs . '\' to your ' + . $hook . ' configuration to execute it.' + ] + ); + } + + /** + * If the hook exists the user has to confirm the action + * + * @param string $hook The name of the hook to check + * @return bool + */ + protected function needConfirmation(string $hook): bool + { + return $this->repository->hookExists($hook) && !$this->force; + } + + /** + * Move the existing hook script to the new location + * + * @param string $originalLocation + * @param string $newLocation + * @return void + * @throws \RuntimeException + */ + protected function moveExistingHook(string $originalLocation, string $newLocation): void + { + $dir = dirname($newLocation); + // make sure the target directory isn't a file + if (file_exists($dir) && !is_dir($dir)) { + throw new RuntimeException($dir . ' is not a directory'); + } + // create the directory if it does not exist + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + // move the hook into the target directory + rename($originalLocation, $newLocation); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook.php b/lib/captainhook/captainhook/src/Runner/Hook.php new file mode 100644 index 0000000000..fb5d3eaaee --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook.php @@ -0,0 +1,490 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Event\Dispatcher; +use CaptainHook\App\Exception\ActionFailed; +use CaptainHook\App\Hook\Constrained; +use CaptainHook\App\Hook\Template\Inspector; +use CaptainHook\App\Plugin; +use CaptainHook\App\Runner\Action\Log as ActionLog; +use CaptainHook\App\Runner\Hook\Log as HookLog; +use CaptainHook\App\Runner\Hook\Printer; +use Exception; +use SebastianFeldmann\Git\Repository; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class Hook extends RepositoryAware +{ + /** + * Hook status constants + */ + public const HOOK_SUCCEEDED = 0; + public const HOOK_FAILED = 1; + + /** + * Hook that should be handled + * + * @var string + */ + protected string $hook; + + /** + * Set to `true` to skip processing this hook's actions + * + * @var bool + */ + private bool $skipActions = false; + + /** + * Event dispatcher + * + * @var \CaptainHook\App\Event\Dispatcher + */ + protected Dispatcher $dispatcher; + + /** + * Plugins to apply to this hook + * + * @var array|null + */ + private ?array $hookPlugins = null; + + /** + * Handling the hook output + * + * @var \CaptainHook\App\Runner\Hook\Printer + */ + private Printer $printer; + + /** + * Logs all output to do it at the end + * + * @var \CaptainHook\App\Runner\Hook\Log + */ + private HookLog $hookLog; + + /** + * Should the plugins be disabled? + * + * @var boolean + */ + private bool $pluginsDisabled = false; + + /** + * Set up the hook runner + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository) + { + $this->dispatcher = new Dispatcher($io, $config, $repository); + $this->printer = new Printer($io); + $this->hookLog = new HookLog(); + + parent::__construct($io, $config, $repository); + } + + /** + * Allow plugins to be disabled + * + * @param bool $disabled + * @return void + */ + public function setPluginsDisabled(bool $disabled): void + { + $this->pluginsDisabled = $disabled; + } + + /** + * Return this hook's name + * + * @return string + */ + public function getName(): string + { + return $this->hook; + } + + /** + * Execute stuff before executing any actions + * + * @return void + */ + public function beforeHook(): void + { + $this->executeHookPluginsFor('beforeHook'); + } + + /** + * Execute stuff before every action + * + * @param Config\Action $action + * @return void + */ + public function beforeAction(Config\Action $action): void + { + $this->executeHookPluginsFor('beforeAction', $action); + } + + /** + * Execute stuff after every action + * + * @param Config\Action $action + * @return void + */ + public function afterAction(Config\Action $action): void + { + $this->executeHookPluginsFor('afterAction', $action); + } + + /** + * Execute stuff after all actions + * + * @return void + */ + public function afterHook(): void + { + $this->executeHookPluginsFor('afterHook'); + } + + /** + * Execute the hook and all its actions + * + * @return void + * @throws \Exception + */ + public function run(): void + { + $this->io->write('' . $this->hook . ': '); + + if (!$this->config->isHookEnabled($this->hook)) { + $this->io->write(' - hook is disabled'); + return; + } + + if ($this->pluginsDisabled) { + $this->io->write('Running with plugins disabled'); + } + + $this->checkHookScript(); + + $this->beforeHook(); + try { + $this->runHook(); + } finally { + $this->afterHook(); + } + } + + /** + * @return void + * @throws \Exception + */ + protected function runHook(): void + { + $hookConfig = $this->config->getHookConfigToExecute($this->hook); + $actions = $hookConfig->getActions(); + // are any actions configured? + if (count($actions) === 0) { + $this->io->write(' - no actions to execute'); + } else { + $this->executeActions($actions); + } + } + + /** + * Returns `true` if something has indicated that the hook should skip all + * remaining actions; pass a boolean value to toggle this + * + * There may be times you want to conditionally skip all actions, based on + * logic in {@see beforeHook()}. Other times, you may wish to skip the rest + * of the actions based on some condition of the current action. + * + * - To skip all actions for a hook, set this to `true` + * in {@see beforeHook()}. + * - To skip the current action and all remaining actions, set this + * to `true` in {@see beforeAction()}. + * - To run the current action but skip all remaining actions, set this + * to `true` in {@see afterAction()}. + * + * @param bool|null $shouldSkip + * @return bool + */ + public function shouldSkipActions(?bool $shouldSkip = null): bool + { + if ($shouldSkip !== null) { + $this->skipActions = $shouldSkip; + } + return $this->skipActions; + } + + /** + * Executes all the Actions configured for the hook + * + * @param \CaptainHook\App\Config\Action[] $actions + * @throws \Exception + */ + private function executeActions(array $actions): void + { + $status = self::HOOK_SUCCEEDED; + $start = microtime(true); + try { + if ($this->config->failOnFirstError()) { + $this->executeFailOnFirstError($actions); + } else { + $this->executeFailAfterAllActions($actions); + } + $this->dispatcher->dispatch('onHookSuccess'); + } catch (Exception $e) { + $status = self::HOOK_FAILED; + $this->dispatcher->dispatch('onHookFailure'); + throw $e; + } finally { + $duration = microtime(true) - $start; + $this->printer->hookEnded($status, $this->hookLog, $duration); + } + } + + /** + * Executes all actions and fails at the first error + * + * @param \CaptainHook\App\Config\Action[] $actions + * @return void + * @throws \Exception + */ + private function executeFailOnFirstError(array $actions): void + { + foreach ($actions as $action) { + $this->handleAction($action); + } + } + + /** + * Executes all actions but does not fail immediately + * + * @param \CaptainHook\App\Config\Action[] $actions + * @return void + * @throws \CaptainHook\App\Exception\ActionFailed + */ + private function executeFailAfterAllActions(array $actions): void + { + $failedActions = 0; + foreach ($actions as $action) { + try { + $this->handleAction($action); + } catch (Exception $exception) { + $failedActions++; + } + } + if ($failedActions > 0) { + throw new ActionFailed($failedActions . ' action' . ($failedActions > 1 ? 's' : '') . ' failed'); + } + } + + /** + * Executes a configured hook action + * + * @param \CaptainHook\App\Config\Action $action + * @return void + * @throws \Exception + */ + private function handleAction(Config\Action $action): void + { + if ($this->shouldSkipActions()) { + $this->printer->actionDeactivated($action); + return; + } + + $io = new IO\CollectorIO($this->io); + $status = ActionLog::ACTION_SUCCEEDED; + + try { + if (!$this->doConditionsApply($action->getConditions(), $io)) { + $this->printer->actionSkipped($action); + return; + } + + $this->beforeAction($action); + + // The beforeAction() method may indicate that the current and all + // remaining actions should be skipped. If so, return here. + if ($this->shouldSkipActions()) { + return; + } + + $runner = $this->createActionRunner(Util::getExecType($action->getAction())); + $runner->execute($this->config, $io, $this->repository, $action); + $this->printer->actionSucceeded($action); + } catch (Exception $e) { + $status = ActionLog::ACTION_FAILED; + $this->printer->actionFailed($action); + if (!$action->isFailureAllowed($this->config->isFailureAllowed())) { + throw $e; + } + $io->write('' . $e->getMessage() . ''); + } finally { + $this->hookLog->addActionLog(new ActionLog($action, $status, $io->getMessages())); + $this->afterAction($action); + } + } + + /** + * Return the right method name to execute an action + * + * @param string $type + * @return \CaptainHook\App\Runner\Action + */ + private function createActionRunner(string $type): Action + { + $valid = [ + 'php' => fn(): Action => new Action\PHP($this->hook, $this->dispatcher), + 'cli' => fn(): Action => new Action\Cli(), + ]; + return $valid[$type](); + } + + /** + * Check if conditions apply + * + * @param \CaptainHook\App\Config\Condition[] $conditions + * @param \CaptainHook\App\Console\IO $collectorIO + * @return bool + */ + private function doConditionsApply(array $conditions, IO $collectorIO): bool + { + $conditionRunner = new Condition($collectorIO, $this->repository, $this->config, $this->hook); + foreach ($conditions as $config) { + $collectorIO->write(' Condition: ' . $config->getExec() . '', true, IO::VERBOSE); + if (!$conditionRunner->doesConditionApply($config)) { + return false; + } + } + return true; + } + + /** + * Return plugins to apply to this hook. + * + * @return array + */ + private function getHookPlugins(): array + { + if ($this->hookPlugins == null) { + $this->hookPlugins = $this->setupHookPlugins(); + } + return $this->hookPlugins; + } + + /** + * + * Setup configured hook plugins + * + * @return array + */ + private function setupHookPlugins(): array + { + $this->hookPlugins = []; + + foreach ($this->config->getPlugins() as $pluginConfig) { + $pluginClass = $pluginConfig->getPlugin(); + if (!is_a($pluginClass, Plugin\Hook::class, true)) { + continue; + } + + $this->io->write( + ['', 'Configuring Hook Plugin: ' . $pluginClass . ''], + true, + IO::VERBOSE + ); + + if ($this->isPluginApplicableForCurrentHook($pluginClass)) { + $this->io->write('Skipped because plugin is not applicable for hook ' . $this->hook, true, IO::VERBOSE); + continue; + } + + $plugin = new $pluginClass(); + $plugin->configure($this->config, $this->io, $this->repository, $pluginConfig); + + $this->hookPlugins[] = $plugin; + } + return $this->hookPlugins; + } + + /** + * Execute hook plugins for the given method name (i.e., beforeHook, + * beforeAction, afterAction, afterHook). + * + * @param string $method + * @param Config\Action|null $action + * @return void + */ + private function executeHookPluginsFor(string $method, ?Config\Action $action = null): void + { + if ($this->pluginsDisabled) { + return; + } + + $plugins = $this->getHookPlugins(); + + if (count($plugins) === 0) { + $this->io->write(['No plugins to execute for: ' . $method . ''], true, IO::DEBUG); + return; + } + + $params = [$this]; + + if ($action !== null) { + $params[] = $action; + } + + $this->io->write(['Executing plugins for: ' . $method . ''], true, IO::DEBUG); + + foreach ($plugins as $plugin) { + $this->io->write('- Running ' . get_class($plugin) . '::' . $method . '', true, IO::DEBUG); + $plugin->{$method}(...$params); + } + } + + /** + * Makes sure the hook script was installed/created with a decent enough version + * + * @return void + * @throws \Exception + */ + private function checkHookScript(): void + { + $inspector = new Inspector($this->hook, $this->io, $this->repository); + $inspector->inspect(); + } + + /** + * @param string $pluginClass + * @return bool + */ + private function isPluginApplicableForCurrentHook(string $pluginClass): bool + { + return is_a($pluginClass, Constrained::class, true) + && !$pluginClass::getRestriction()->isApplicableFor($this->hook); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/Arg.php b/lib/captainhook/captainhook/src/Runner/Hook/Arg.php new file mode 100644 index 0000000000..6d551a95cd --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/Arg.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Exception\InvalidHookName; + +/** + * Hook argument for lots of commands + * + * - install pre-push,pre-commit + * - info commit-message + */ +class Arg +{ + /** + * List of hooks + * + * @var array + */ + private array $hooks = []; + + /** + * @param string $hook + * @param callable $hookValidation + * @throws \CaptainHook\App\Exception\InvalidHookName + */ + public function __construct(string $hook, callable $hookValidation) + { + if (empty($hook)) { + return; + } + + /** @var array $hooks */ + $hooks = explode(',', $hook); + $hooks = array_map('trim', $hooks); + + if (!empty(($invalidHooks = array_filter($hooks, $hookValidation)))) { + throw new InvalidHookName( + 'Invalid hook name \'' . implode('\', \'', $invalidHooks) . '\'' + ); + } + $this->hooks = $hooks; + } + + /** + * Return the list of hooks provided as an argument + * + * @return array + */ + public function hooks(): array + { + return $this->hooks; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/CommitMsg.php b/lib/captainhook/captainhook/src/Runner/Hook/CommitMsg.php new file mode 100644 index 0000000000..282e88b203 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/CommitMsg.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; +use SebastianFeldmann\Git; + +/** + * CommitMsg + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class CommitMsg extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::COMMIT_MSG; + + /** + * Read the commit message from file + */ + public function beforeHook(): void + { + $commentChar = $this->repository->getConfigOperator()->getSettingSafely('core.commentchar', '#'); + $commitMsg = Git\CommitMessage::createFromFile( + $this->io->getArgument(Hooks::ARG_MESSAGE_FILE, ''), + $commentChar + ); + + $this->repository->setCommitMsg($commitMsg); + + parent::beforeHook(); + } + + /** + * Makes sure we do not run commit message validation for fixup commits + * + * @return void + * @throws \Exception + */ + protected function runHook(): void + { + $msg = $this->repository->getCommitMsg(); + if ($msg->isFixup()) { + $this->io->write(' - no commit message validation for fixup commits: skipping all actions'); + return; + } + parent::runHook(); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/Log.php b/lib/captainhook/captainhook/src/Runner/Hook/Log.php new file mode 100644 index 0000000000..b37c23b0dd --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/Log.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Runner\Action\Log as ActionLog; + +class Log +{ + /** + * List if all Action Logs + * + * @var array<\CaptainHook\App\Runner\Action\Log> + */ + private array $logs = []; + + /** + * Adds an action log to the hook log + * + * @param \CaptainHook\App\Runner\Action\Log $actionLog + * @return void + */ + public function addActionLog(ActionLog $actionLog): void + { + $this->logs[] = $actionLog; + } + + /** + * Checks if any of the collected action logs has a message to display + * + * @param int $verbosity + * @return bool + */ + public function hasMessageForVerbosity(int $verbosity): bool + { + foreach ($this->logs as $actionLog) { + if ($actionLog->hasMessageForVerbosity($verbosity)) { + return true; + } + } + return false; + } + + /** + * Returns all collected action logs + * + * @return array<\CaptainHook\App\Runner\Action\Log> + */ + public function logs(): array + { + return $this->logs; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/PostCheckout.php b/lib/captainhook/captainhook/src/Runner/Hook/PostCheckout.php new file mode 100644 index 0000000000..168ef736ec --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/PostCheckout.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 4.1.0 + */ +class PostCheckout extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::POST_CHECKOUT; +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/PostCommit.php b/lib/captainhook/captainhook/src/Runner/Hook/PostCommit.php new file mode 100644 index 0000000000..01d5f8565c --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/PostCommit.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class PostCommit extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::POST_COMMIT; +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/PostMerge.php b/lib/captainhook/captainhook/src/Runner/Hook/PostMerge.php new file mode 100644 index 0000000000..1c0d5dd11c --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/PostMerge.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class PostMerge extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::POST_MERGE; +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/PostRewrite.php b/lib/captainhook/captainhook/src/Runner/Hook/PostRewrite.php new file mode 100644 index 0000000000..2c6647653c --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/PostRewrite.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.4.0 + */ +class PostRewrite extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::POST_REWRITE; +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/PreCommit.php b/lib/captainhook/captainhook/src/Runner/Hook/PreCommit.php new file mode 100644 index 0000000000..f47cfd7c36 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/PreCommit.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class PreCommit extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::PRE_COMMIT; +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/PrePush.php b/lib/captainhook/captainhook/src/Runner/Hook/PrePush.php new file mode 100644 index 0000000000..14b7395f90 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/PrePush.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class PrePush extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::PRE_PUSH; +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/PrepareCommitMsg.php b/lib/captainhook/captainhook/src/Runner/Hook/PrepareCommitMsg.php new file mode 100644 index 0000000000..caa640806e --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/PrepareCommitMsg.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Config; +use CaptainHook\App\Hooks; +use CaptainHook\App\Runner\Hook; +use CaptainHook\App\Runner\Util; +use RuntimeException; +use SebastianFeldmann\Git; + +/** + * Hook + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 3.1.0 + */ +class PrepareCommitMsg extends Hook +{ + /** + * Hook to execute + * + * @var string + */ + protected string $hook = Hooks::PREPARE_COMMIT_MSG; + + /** + * @var string + */ + private string $commentChar; + + /** + * Path to commit message file + * + * @var string + */ + private string $file; + + /** + * Commit mode, empty or [message|template|merge|squash|commit] + * + * @var string + */ + private string $mode; + + /** + * Commit hash if mode is commit during -c or --amend + * + * @var string + */ + private string $hash; + + /** + * Fetch the original hook arguments and message related config settings + * + * @return void + */ + public function beforeHook(): void + { + $this->commentChar = $this->repository->getConfigOperator()->getSettingSafely('core.commentchar', '#'); + $this->file = $this->io->getArgument(Hooks::ARG_MESSAGE_FILE); + $this->mode = $this->io->getArgument(Hooks::ARG_MODE); + $this->hash = $this->io->getArgument(Hooks::ARG_HASH); + + if (empty($this->file)) { + throw new RuntimeException('commit message file argument is missing'); + } + + parent::beforeHook(); + } + + /** + * Read the commit message from file + * + * @param Config\Action $action + * @return void + */ + public function beforeAction(Config\Action $action): void + { + $this->repository->setCommitMsg(Git\CommitMessage::createFromFile($this->file, $this->commentChar)); + parent::beforeAction($action); + } + + /** + * Write the commit message to disk so git or the next action can proceed further + * + * @param Config\Action $action + * @return void + */ + public function afterAction(Config\Action $action): void + { + // only write the commit message to disk if a php action was executed + // and potentially changed the message object + if (Util::getExecType($action->getAction()) !== 'cli') { + file_put_contents($this->file, $this->repository->getCommitMsg()->getRawContent()); + } + parent::afterAction($action); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Hook/Printer.php b/lib/captainhook/captainhook/src/Runner/Hook/Printer.php new file mode 100644 index 0000000000..4c5c78c44b --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Hook/Printer.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner\Hook; + +use CaptainHook\App\Config\Action; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Runner\Action\Log as ActionLog; +use CaptainHook\App\Runner\Hook; + +/** + * Class Printer + * + * Is handling the output for the hook execution + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.19.0 + */ +class Printer +{ + /** + * @var \CaptainHook\App\Console\IO + */ + private IO $io; + + /** + * Current verbosity + * + * @var int + */ + private int $verbosity; + + /** + * Constructor + * + * @param \CaptainHook\App\Console\IO $io + */ + public function __construct(IO $io) + { + $this->io = $io; + if ($io->isDebug()) { + $this->verbosity = IO::DEBUG; + } elseif ($io->isVeryVerbose()) { + $this->verbosity = IO::VERY_VERBOSE; + } elseif ($io->isVerbose()) { + $this->verbosity = IO::VERBOSE; + } else { + $this->verbosity = IO::NORMAL; + } + } + + /** + * Prints the action success line + * + * @param \CaptainHook\App\Config\Action $action + * @return void + */ + public function actionSucceeded(Action $action): void + { + $this->io->write($this->actionHeadline($action) . 'done'); + } + + /** + * Prints the action skipped line + * + * @param \CaptainHook\App\Config\Action $action + * @return void + */ + public function actionSkipped(Action $action): void + { + $this->io->write($this->actionHeadline($action) . 'skipped'); + } + + /** + * Prints the action failed line + * + * @param \CaptainHook\App\Config\Action $action + * @return void + */ + public function actionFailed(Action $action): void + { + $this->io->write($this->actionHeadline($action) . 'failed'); + } + + /** + * Prints the action deactivated line + * + * @param \CaptainHook\App\Config\Action $action + * @return void + */ + public function actionDeactivated(Action $action): void + { + $this->io->write($this->actionHeadline($action) . 'deactivated'); + } + + private function actionHeadline(Action $action): string + { + return ' - ' . $this->formatActionHeadline($action->getLabel()) . ' : '; + } + + /** + * Some fancy output formatting + * + * @param string $action + * @return string + */ + private function formatActionHeadline(string $action): string + { + $actionLength = 65; + if (mb_strlen($action) < $actionLength) { + return str_pad($action, $actionLength, ' '); + } + + return mb_substr($action, 0, $actionLength - 3) . '...'; + } + + /** + * Prints the action log + * + * @param int $status + * @param \CaptainHook\App\Runner\Hook\Log $log + * @param float $seconds + * @return void + */ + public function hookEnded(int $status, Log $log, float $seconds): void + { + $msg = 'captainhook executed all actions successfully, took: %01.2fs'; + if ($status === Hook::HOOK_FAILED) { + $msg = 'captainhook failed executing all actions, took: %01.2fs'; + } + $this->io->write(sprintf($msg, $seconds)); + + $tags = [ + ActionLog::ACTION_FAILED => 'fg=red', + ActionLog::ACTION_SKIPPED => 'fg=yellow', + ActionLog::ACTION_SUCCEEDED => 'fg=green', + ]; + if ($log->hasMessageForVerbosity($this->verbosity)) { + $this->io->write(''); + foreach ($log->logs() as $actionLog) { + if ($actionLog->hasMessageForVerbosity($this->verbosity)) { + $tag = $tags[$actionLog->status()]; + $this->io->write('<' . $tag . '>' . $actionLog->name() . ''); + foreach ($actionLog->messages() as $msg) { + $this->io->write($msg->message(), $msg->newLine(), $msg->verbosity()); + } + } + } + } + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Installer.php b/lib/captainhook/captainhook/src/Runner/Installer.php new file mode 100644 index 0000000000..b3f6490c02 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Installer.php @@ -0,0 +1,299 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Runner; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Console\IOUtil; +use CaptainHook\App\Hook\Template; +use CaptainHook\App\Storage\File; +use RuntimeException; +use SebastianFeldmann\Camino\Check; +use SebastianFeldmann\Git\Repository; + +/** + * Class Installer + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class Installer extends Files +{ + /** + * Don't overwrite existing hooks + * + * @var bool + */ + private bool $skipExisting = false; + + /** + * Install only enabled hooks + * + * @var bool + */ + private bool $onlyEnabled = false; + + /** + * Hook template + * + * @var \CaptainHook\App\Hook\Template + */ + private Template $template; + + /** + * HookHandler constructor. + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + * @param \CaptainHook\App\Hook\Template $template + */ + public function __construct(IO $io, Config $config, Repository $repository, Template $template) + { + $this->template = $template; + parent::__construct($io, $config, $repository); + } + + /** + * @param bool $skip + * @return \CaptainHook\App\Runner\Installer + */ + public function setSkipExisting(bool $skip): Installer + { + if ($skip && !empty($this->moveExistingTo)) { + throw new RuntimeException('choose --move-existing-to or --skip-existing'); + } + $this->skipExisting = $skip; + return $this; + } + + /** + * Set the path where the current hooks should be moved to + * + * @param string $backup + * @return static + */ + public function setMoveExistingTo(string $backup): static + { + if (!empty($backup) && $this->skipExisting) { + throw new RuntimeException('choose --skip-existing or --move-existing-to'); + } + return parent::setMoveExistingTo($backup); + } + + /** + * @param bool $onlyEnabled + * @return \CaptainHook\App\Runner\Installer + */ + public function setOnlyEnabled(bool $onlyEnabled): Installer + { + if ($onlyEnabled && !empty($this->hooksToHandle)) { + throw new RuntimeException('choose --only-enabled or specific hooks'); + } + + $this->onlyEnabled = $onlyEnabled; + return $this; + } + + /** + * Hook setter + * + * @param string $hook + * @return \CaptainHook\App\Runner\Installer + * @throws \CaptainHook\App\Exception\InvalidHookName + */ + public function setHook(string $hook): Installer + { + if (empty($hook)) { + return $this; + } + + if ($this->onlyEnabled) { + throw new RuntimeException('choose --only-enabled or specific hooks'); + } + + return parent::setHook($hook); + } + + /** + * Execute installation + * + * @return void + */ + public function run(): void + { + if (!$this->shouldRun()) { + return; + } + foreach ($this->getHooksToInstall() as $hook => $ask) { + $this->installHook($hook, ($ask && !$this->force)); + } + } + + /** + * Return list of hooks to install + * + * + * [ + * string => bool + * HOOK_NAME => ASK_USER_TO_CONFIRM_INSTALL + * ] + * + * + * @return array + */ + public function getHooksToInstall(): array + { + $hooks = $this->getHooksToHandle(); + // if only enabled hooks should be installed, remove disabled ones from the $hooks array + if ($this->onlyEnabled) { + $hooks = array_filter( + $hooks, + fn(string $key): bool => $this->config->isHookEnabled($key), + ARRAY_FILTER_USE_KEY + ); + } + // make sure to ask for every remaining hook if it should be installed + return $hooks; + } + + /** + * Install given hook + * + * @param string $hook + * @param bool $ask + */ + private function installHook(string $hook, bool $ask): void + { + if ($this->shouldHookBeSkipped($hook)) { + $hint = $this->io->isDebug() ? ', remove the --skip-existing option to overwrite.' : ''; + $this->io->write( + IOUtil::PREFIX_FAIL . ' ' . $hook . ' exists' . $hint, + true, + IO::VERBOSE + ); + return; + } + + $doIt = true; + if ($ask) { + $answer = $this->io->ask('Install ' . $hook . ' hook? [Y,n] ', 'y'); + $doIt = IOUtil::answerToBool($answer); + } + + if ($doIt) { + if ($this->shouldHookBeMoved()) { + $this->backupHook($hook); + } + $this->writeHookFile($hook); + } + } + + /** + * Check if the hook is installed and should be skipped + * + * @param string $hook + * @return bool + */ + private function shouldHookBeSkipped(string $hook): bool + { + return $this->skipExisting && $this->repository->hookExists($hook); + } + + /** + * Write given hook to .git/hooks directory + * + * @param string $hook + * @return void + */ + private function writeHookFile(string $hook): void + { + $hooksDir = $this->repository->getHooksDir(); + $hookFile = $hooksDir . DIRECTORY_SEPARATOR . $hook; + $doIt = true; + + // if a hook is configured and no force option is set, + // ask the user if overwriting the hook is ok + if ($this->needConfirmation($hook)) { + $ans = $this->io->ask( + 'The ' . $hook . ' hook exists! Overwrite? [y,N] ', + 'n' + ); + $doIt = IOUtil::answerToBool($ans); + } + + if ($doIt) { + $code = $this->getHookSourceCode($hook); + $file = new File($hookFile); + $this->checkForBrokenSymlink($file); + $file->write($code); + chmod($hookFile, 0755); + $this->io->write(IOUtil::PREFIX_OK . ' ' . $hook . ' installed'); + return; + } + $this->io->write(IOUtil::PREFIX_FAIL . ' ' . $hook . ' skipped'); + } + + /** + * Return the source code for a given hook script + * + * @param string $hook + * @return string + */ + private function getHookSourceCode(string $hook): string + { + return $this->template->getCode($hook); + } + + /** + * Checks if the provided file is a broken symbolic link + * + * @param File $file The File object representing the file. + * @return void + * @throws RuntimeException If the file is determined to be a broken symbolic link. + */ + protected function checkForBrokenSymlink(File $file): void + { + if ($file->isLink()) { + $target = $file->linkTarget(); + if (!Check::isAbsolutePath($target)) { + $target = dirname($file->getPath()) . DIRECTORY_SEPARATOR . $target; + } + if (!is_dir(dirname($target))) { + throw new RuntimeException( + 'The hook at \'' . $file->getPath() . '\' is a broken symbolic link. ' . PHP_EOL . + 'Please remove the symbolic link and try again.' + ); + } + } + } + + /** + * Check for problems + * + * @return bool + */ + private function shouldRun(): bool + { + $hooksDir = $this->repository->getHooksDir(); + // check nix and win systems black holes + if ($hooksDir === '/dev/null' || $hooksDir === 'NUL') { + $this->io->write('can\'t install hooks into hooksPath: /dev/null'); + return false; + } + return true; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/RepositoryAware.php b/lib/captainhook/captainhook/src/Runner/RepositoryAware.php new file mode 100644 index 0000000000..13c0603767 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/RepositoryAware.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner; + +use CaptainHook\App\Config; +use CaptainHook\App\Console\IO; +use CaptainHook\App\Runner; +use SebastianFeldmann\Git\Repository; + +/** + * Class HookHandler + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +abstract class RepositoryAware extends Runner +{ + /** + * Git repository. + * + * @var \SebastianFeldmann\Git\Repository + */ + protected $repository; + + /** + * HookHandler constructor. + * + * @param \CaptainHook\App\Console\IO $io + * @param \CaptainHook\App\Config $config + * @param \SebastianFeldmann\Git\Repository $repository + */ + public function __construct(IO $io, Config $config, Repository $repository) + { + parent::__construct($io, $config); + $this->repository = $repository; + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Uninstaller.php b/lib/captainhook/captainhook/src/Runner/Uninstaller.php new file mode 100644 index 0000000000..fc6f12b36a --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Uninstaller.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\App\Runner; + +use CaptainHook\App\Console\IOUtil; +use RuntimeException; + +/** + * Class Uninstaller + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 5.17.0 + */ +class Uninstaller extends Files +{ + /** + * Remove only disabled hooks + * + * @var bool + */ + private bool $onlyDisabled = false; + + /** + * Execute installation + * + * @return void + */ + public function run(): void + { + foreach ($this->getHooksToUninstall() as $hook => $ask) { + $this->uninstallHook($hook, ($ask && !$this->force)); + } + } + + /** + * Disabled only setter + * + * @param bool $disabledOnly + * @return \CaptainHook\App\Runner\Uninstaller + */ + public function setOnlyDisabled(bool $disabledOnly): Uninstaller + { + if ($disabledOnly && !empty($this->hooksToHandle)) { + throw new RuntimeException('choose --only-disabled or specific hooks'); + } + $this->onlyDisabled = $disabledOnly; + return $this; + } + + /** + * Returns the list of hooks to uninstall + * + * [ + * string => bool + * HOOK_NAME => ASK_USER_TO_CONFIRM_INSTALL + * ] + * + * @return array + */ + private function getHooksToUninstall(): array + { + $hooks = $this->getHooksToHandle(); + + // if only disabled hooks should be removed, remove enabled ones from $hooks array + if ($this->onlyDisabled) { + $hooks = array_filter( + $hooks, + fn(string $key): bool => !$this->config->isHookEnabled($key), + ARRAY_FILTER_USE_KEY + ); + } + return $hooks; + } + + /** + * Install given hook + * + * @param string $hook + * @param bool $ask + */ + private function uninstallHook(string $hook, bool $ask): void + { + if (!$this->repository->hookExists($hook)) { + $this->io->write('' . $hook . ' not installed'); + return; + } + + $doIt = true; + if ($ask) { + $answer = $this->io->ask('Remove ' . $hook . ' hook? [y,n] ', 'y'); + $doIt = IOUtil::answerToBool($answer); + } + + if ($doIt) { + if ($this->shouldHookBeMoved()) { + $this->backupHook($hook); + return; + } + unlink($this->repository->getHooksDir() . DIRECTORY_SEPARATOR . $hook); + $this->io->write(IOUtil::PREFIX_OK . ' ' . $hook . ' removed'); + return; + } + $this->io->write(IOUtil::PREFIX_FAIL . ' ' . $hook . ' skipped'); + } +} diff --git a/lib/captainhook/captainhook/src/Runner/Util.php b/lib/captainhook/captainhook/src/Runner/Util.php new file mode 100644 index 0000000000..3b3b6e4587 --- /dev/null +++ b/lib/captainhook/captainhook/src/Runner/Util.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Runner; + +/** + * Class Util + * + * @package CaptainHook\App\Runner + */ +final class Util +{ + /** + * List of valid action types + * + * @var array + */ + private static $validTypes = ['php' => true, 'cli' => true]; + + + /** + * Check the validity of a exec type + * + * @param string $type + * @return bool + */ + public static function isTypeValid(string $type): bool + { + return isset(self::$validTypes[$type]); + } + + /** + * Return action type + * + * @param string $action + * @return string + */ + public static function getExecType(string $action): string + { + return substr($action, 0, 1) === '\\' ? 'php' : 'cli'; + } + + /** + * Try to read an environment variable + * + * @param string $name + * @param string $default + * @return string + */ + public static function getEnv(string $name, string $default = ''): string + { + $var = getenv($name); + return $var ?: $_ENV[$name] ?? $_SERVER[$name] ?? $default; + } +} diff --git a/lib/captainhook/captainhook/src/Storage/File.php b/lib/captainhook/captainhook/src/Storage/File.php new file mode 100644 index 0000000000..79c11cd7bd --- /dev/null +++ b/lib/captainhook/captainhook/src/Storage/File.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Storage; + +use RuntimeException; + +/** + * Class File + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +class File +{ + /** + * Path to file + * + * @var string + */ + protected string $path; + + /** + * File constructor. + * + * @param string $path + */ + public function __construct(string $path) + { + $this->path = $path; + } + + /** + * Path getter. + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Checks whether the file exists. + * + * @return bool + */ + public function exists(): bool + { + return is_file($this->path); + } + + /** + * Reads json file. + * + * @return mixed + * @throws \RuntimeException + */ + public function read() + { + if (!file_exists($this->path)) { + throw new RuntimeException('Could not read ' . $this->path); + } + return file_get_contents($this->path); + } + + /** + * Writes file. + * + * @param string $content + * @throws \RuntimeException + */ + public function write($content): void + { + $this->checkFile(); + $this->checkDir(); + + file_put_contents($this->path, $content); + } + + /** + * Check if file exists and isn't writable + * + * @return void + * @throws \RuntimeException + */ + private function checkFile(): void + { + if (file_exists($this->path) && !is_writable($this->path)) { + throw new RuntimeException('File exists and is not writable'); + } + } + + /** + * Create directory if necessary + * + * @return void + * @throws \RuntimeException + */ + private function checkDir(): void + { + $dir = dirname($this->path); + if (!is_dir($dir)) { + if (file_exists($dir)) { + throw new RuntimeException($dir . ' exists and is not a directory.'); + } + if (!@mkdir($dir, 0755, true)) { + throw new RuntimeException($dir . ' does not exist and could not be created.'); + } + } + } + + /** + * Determines if the given path is a symbolic link + * + * @return bool True if the path is a symbolic link, false otherwise. + */ + public function isLink(): bool + { + return is_link($this->path); + } + + /** + * Resolves and returns the target of a symbolic link + * + * @return string The resolved target of the symbolic link. + */ + public function linkTarget(): string + { + if (!$this->isLink()) { + throw new RuntimeException('Not a symbolic link: ' . $this->path); + } + return (string)readlink($this->path); + } +} diff --git a/lib/captainhook/captainhook/src/Storage/File/Json.php b/lib/captainhook/captainhook/src/Storage/File/Json.php new file mode 100644 index 0000000000..585ea73c05 --- /dev/null +++ b/lib/captainhook/captainhook/src/Storage/File/Json.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Storage\File; + +use CaptainHook\App\Storage\File; +use RuntimeException; +use stdClass; + +/** + * Class Json + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 0.9.0 + */ +final class Json extends File +{ + /** + * Read and decode the json file + * + * @param bool $assoc + * @return \stdClass|array|null + */ + public function read(bool $assoc = false): array|stdClass|null + { + $json = json_decode(parent::read(), $assoc); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException('Invalid json file'); + } + return $json; + } + + /** + * Read the file and decode to assoc array + * + * @return array + */ + public function readAssoc(): array + { + return (array) ($this->read(true) ?? []); + } + + /** + * Encode content to json and write to disk + * + * @param mixed $content + * @param int $options + * @return void + */ + public function write($content, $options = 448): void + { + $json = json_encode($content, $options) . ($options & JSON_PRETTY_PRINT ? "\n" : ''); + parent::write($json); + } +} diff --git a/lib/captainhook/captainhook/src/Storage/File/Xml.php b/lib/captainhook/captainhook/src/Storage/File/Xml.php new file mode 100644 index 0000000000..1e9ced1394 --- /dev/null +++ b/lib/captainhook/captainhook/src/Storage/File/Xml.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\App\Storage\File; + +use CaptainHook\App\Storage\File; +use RuntimeException; + +/** + * Class Xml + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/captainhook + * @since Class available since Release 1.2.0 + */ +final class Xml extends File +{ + /** + * Read the xml file and return a SimpleXML object. + * + * @return \SimpleXMLElement + */ + public function read() + { + $old = libxml_use_internal_errors(true); + $xml = simplexml_load_file($this->path); + $errors = libxml_get_errors(); + libxml_use_internal_errors($old); + + if (count($errors) || $xml === false) { + throw new RuntimeException('xml file \'' . $this->path . '\': ' . $errors[0]->message); + } + return $xml; + } +} diff --git a/lib/captainhook/secrets/LICENSE b/lib/captainhook/secrets/LICENSE new file mode 100644 index 0000000000..813cf7d988 --- /dev/null +++ b/lib/captainhook/secrets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Sebastian Feldmann + +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/lib/captainhook/secrets/captainhook.json b/lib/captainhook/secrets/captainhook.json new file mode 100644 index 0000000000..6294d234ec --- /dev/null +++ b/lib/captainhook/secrets/captainhook.json @@ -0,0 +1,98 @@ +{ + "config": { + "verbosity": "normal", + "fail-on-first-error": false, + "ansi-colors": true, + "git-directory": ".git", + "includes": [], + "run-mode": "shell", + "run-cmd": "tools/captainhook", + "bootstrap": "vendor/autoload.php" + }, + "commit-msg": { + "enabled": true, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Beams", + "options": { + "subjectLength": 50, + "bodyLineLength": 72 + }, + "config": { + "label": "Verify commit message format" + } + } + ] + }, + "pre-push": { + "enabled": true, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\Branch\\Action\\BlockFixupAndSquashCommits", + "options": { + "protectedBranches": ["main", "master"] + }, + "config": { + "label": "Block fixup commits from main" + } + }, + { + "action": "tools/phpstan analyse", + "conditions": [ + { + "exec": "\\CaptainHook\\App\\Hook\\Condition\\FileChanged\\OfType", + "args": [ + "php" + ] + } + ], + "config": { + "label": "Static code analysis" + } + } + ] + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\PHP\\Action\\Linting", + "config": { + "label": "Lint PHP files" + } + }, + { + "action": "\\CaptainHook\\App\\Hook\\File\\Action\\MaxSize", + "config": { + "label": "Max size check" + }, + "options": { + "maxSize": "1M" + } + }, + { + "action": "tools/phpunit --no-coverage" + }, + { + "action": "tools/phpcs --colors --standard=psr12 {$STAGED_FILES|of-type:php|separated-by: }", + "conditions": [ + { + "exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\OfType", + "args": ["php"] + } + ] + }, + { + "action": "\\CaptainHook\\App\\Hook\\Composer\\Action\\CheckLockFile" + } + ] + }, + "post-change": { + "enabled": true, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\Notify\\Action\\Notify" + } + ] + } +} diff --git a/lib/captainhook/secrets/composer.json b/lib/captainhook/secrets/composer.json new file mode 100644 index 0000000000..788df33e55 --- /dev/null +++ b/lib/captainhook/secrets/composer.json @@ -0,0 +1,43 @@ +{ + "name": "captainhook/secrets", + "type": "library", + "description": "Utility classes to detect secrets", + "keywords": ["secrets", "passwords", "keys", "tokens", "commit-msg", "prepare-commit-msg", "post-merge"], + "license": "MIT", + "support": { + "issues": "https://github.com/captainhook-git/secrets/issues" + }, + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sebastianfeldmann" + } + ], + "autoload": { + "psr-4": { + "CaptainHook\\Secrets\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CaptainHook\\Secrets\\": "tests/" + } + }, + "require": { + "php": ">=8.0", + "ext-mbstring": "*" + }, + "scripts": { + "post-install-cmd": "tools/phive install --force-accept-unsigned", + "tools": "tools/phive install --force-accept-unsigned", + "test": "tools/phpunit", + "static": "tools/phpstan analyse", + "style": "tools/phpcs --standard=psr12 src tests" + } +} diff --git a/lib/captainhook/secrets/src/Detector.php b/lib/captainhook/secrets/src/Detector.php new file mode 100644 index 0000000000..fe35bdb7dc --- /dev/null +++ b/lib/captainhook/secrets/src/Detector.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets; + +use CaptainHook\Secrets\Regex\Supplier; +use RuntimeException; + +/** + * Secret Detector + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class Detector +{ + /** + * @var array + */ + private array $patterns = []; + + /** + * Creates a new Detector + * + * @return \CaptainHook\Secrets\Detector + */ + public static function create(): self + { + return new self(); + } + + /** + * Add a list of wanted suppliers + * This takes care of creating and validating the configured suppliers. + * + * @param array $config List of class names + * @return $this + */ + public function useSupplierConfig(array $config): self + { + $suppliers = []; + foreach ($config as $class) { + if (!class_exists($class)) { + throw new RuntimeException('class not found:' . $class); + } + $supplier = new $class(); + if (!$supplier instanceof Supplier) { + throw new RuntimeException('class is not a supplier:' . $class); + } + $suppliers[] = $supplier; + } + return $this->useSuppliers(...$suppliers); + } + + /** + * Adds the regular expressions of a Supplier to the list + * + * @param \CaptainHook\Secrets\Regex\Supplier ...$suppliers The RegexSupplier to add + * @return $this + */ + public function useSuppliers(Supplier ...$suppliers): self + { + foreach ($suppliers as $supplier) { + $this->useRegex(...$supplier->patterns()); + } + return $this; + } + + /** + * Add regular expressions to the detector + * + * @param string ...$regularExpressions Regular expression to detect secret + * @return $this + */ + public function useRegex(string ...$regularExpressions): self + { + foreach ($regularExpressions as $regularExpression) { + $this->patterns[] = $regularExpression; + } + + return $this; + } + + /** + * Detect secrets in string + * + * @param string $content + * @return \CaptainHook\Secrets\Result + */ + public function detectIn(string $content): Result + { + $allMatches = []; + foreach ($this->patterns as $regex) { + $matches = []; + if (preg_match($regex, $content, $matches)) { + $allMatches[] = $matches[0]; + } + } + return new Result($allMatches); + } +} diff --git a/lib/captainhook/secrets/src/Entropy/Shannon.php b/lib/captainhook/secrets/src/Entropy/Shannon.php new file mode 100644 index 0000000000..e67de2045c --- /dev/null +++ b/lib/captainhook/secrets/src/Entropy/Shannon.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Entropy; + +/** + * Secret Detector + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class Shannon +{ + /** + * Return the entropy of a given string + * + * @param string $text + * @return float + */ + public static function entropy(string $text): float + { + if (empty($text)) { + return 0; + } + + $charMap = []; + foreach (mb_str_split($text) as $char) { + $charMap[$char] = isset($charMap[$char]) ? $charMap[$char] + 1 : 1; + } + + $entropy = 0; + $length = strlen($text); + foreach ($charMap as $char => $amount) { + $p = $amount / $length; + $entropy = $entropy - ($p * log($p, 2)); + } + return $entropy; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Grouped.php b/lib/captainhook/secrets/src/Regex/Grouped.php new file mode 100644 index 0000000000..a85809d46a --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Grouped.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex; + +/** + * Grouped Interface + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.4 + */ +interface Grouped extends Supplier +{ + /** + * Returns the capture group index of the potential password + * + * @return array + */ + public function indexes(): array; +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier.php b/lib/captainhook/secrets/src/Regex/Supplier.php new file mode 100644 index 0000000000..10f008178a --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex; + +/** + * Supplier Interface + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +interface Supplier +{ + /** + * Return a list of regex patterns + * + * @return array + */ + public function patterns(): array; +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Aws.php b/lib/captainhook/secrets/src/Regex/Supplier/Aws.php new file mode 100644 index 0000000000..1dbae61ed8 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Aws.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Supplier; + +/** + * Aws regex supplier + * + * Provides the regex to find AWS secrets. + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class Aws implements Supplier +{ + /** + * Possible AWS pattern + */ + public const AWS = '(AWS|aws|Aws)?_?'; + + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + // AWS token + '#(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}#', + + // AWS secrets, keys, access token + '#' . Util::OPTIONAL_QUOTE . self::AWS . '(SECRET|secret|Secret)?_?(ACCESS|access|Access)?_?(KEY|key|Key)' + . Util::OPTIONAL_QUOTE . Util::CONNECT + . Util::OPTIONAL_QUOTE . '([A-Za-z0-9/\\+=]{40})' . Util::OPTIONAL_QUOTE . '#', + + // AWS account id + '#' . Util::OPTIONAL_QUOTE . self::AWS . '(ACCOUNT|account|Account)_?(ID|id|Id)?' . Util::OPTIONAL_QUOTE + . Util::CONNECT . Util::OPTIONAL_QUOTE . '([0-9]{4}\\-?[0-9]{4}\\-?[0-9]{4})' . Util::OPTIONAL_QUOTE . '#', + ]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/GitHub.php b/lib/captainhook/secrets/src/Regex/Supplier/GitHub.php new file mode 100644 index 0000000000..5d72377da2 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/GitHub.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Supplier; + +/** + * GitHub regex supplier + * + * Provides the regex to find GitHub secrets. + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class GitHub implements Supplier +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + // Personal Access Token (Classic) + '#' . Util::OPTIONAL_QUOTE . '(ghp_[a-zA-Z0-9]{36})' . Util::OPTIONAL_QUOTE . '#', + + // Personal Access Token (Fine-Grained) + '#' . Util::OPTIONAL_QUOTE . '(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})' . Util::OPTIONAL_QUOTE . '#', + + // User-To-Server Access Token + '#' . Util::OPTIONAL_QUOTE . '(ghu_[a-zA-Z0-9]{36})' . Util::OPTIONAL_QUOTE . '#', + + // Server-To-Server Access Token + '#' . Util::OPTIONAL_QUOTE . '(ghs_[a-zA-Z0-9]{36})' . Util::OPTIONAL_QUOTE . '#', + ]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Gitlab.php b/lib/captainhook/secrets/src/Regex/Supplier/Gitlab.php new file mode 100644 index 0000000000..aec3bc6763 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Gitlab.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Supplier; + +/** + * Gitlab regex + * + * Provides the regex to find Gitlab secrets. + * + * @package CaptainHook-Secrets + * @since Class available since Release 0.9.6 + */ +final class Gitlab implements Supplier +{ + /** + * Sourced from the gitlab secret detection + * https://github.com/gitlabhq/gitlabhq/blob/master/gems/gitlab-secret_detection/lib/gitleaks.toml#L4-L51 + * + * @return string[] + */ + public function patterns(): array + { + return [ + // GitLab Personal Access Token + '#' . Util::OPTIONAL_QUOTE . '(glpat-[0-9a-zA-Z_\\-]{20})' . Util::OPTIONAL_QUOTE . '#', + // GitLab Pipeline Trigger Token + '#' . Util::OPTIONAL_QUOTE . '(glptt-[0-9a-zA-Z_\\-]{40})' . Util::OPTIONAL_QUOTE . '#', + // GitLab Runner Registration Token + '#' . Util::OPTIONAL_QUOTE . '(GR1348941[0-9a-zA-Z_\\-]{20})' . Util::OPTIONAL_QUOTE . '#', + // GitLab OAuth Application Secrets + '#' . Util::OPTIONAL_QUOTE . '(gloas-[0-9a-zA-Z_\\-]{64})' . Util::OPTIONAL_QUOTE . '#', + // GitLab Feed token + '#' . Util::OPTIONAL_QUOTE . '(glft-[0-9a-zA-Z_\\-]{20})' . Util::OPTIONAL_QUOTE . '#', + // GitLab Agent for Kubernetes token + '#' . Util::OPTIONAL_QUOTE . '(glagent-[0-9a-zA-Z_\\-]{50})' . Util::OPTIONAL_QUOTE . '#', + // GitLab Incoming email token + '#' . Util::OPTIONAL_QUOTE . '(glimt-[0-9a-zA-Z_\\-]{25})' . Util::OPTIONAL_QUOTE . '#', + ]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Google.php b/lib/captainhook/secrets/src/Regex/Supplier/Google.php new file mode 100644 index 0000000000..5562779c5b --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Google.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Supplier; + +/** + * Google regex + * + * Provides the regex to find Google secrets. + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class Google implements Supplier +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + // API Key + '#' . Util::OPTIONAL_QUOTE . '(AIza[0-9A-Za-z\-_]{35})' . Util::OPTIONAL_QUOTE . '#', + ]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Ini.php b/lib/captainhook/secrets/src/Regex/Supplier/Ini.php new file mode 100644 index 0000000000..69e0e89533 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Ini.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Grouped; + +/** + * Find any possible string assignment in a php file + * + * Finds: + * - foo = "string" + * - foo = string + */ +class Ini implements Grouped +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + '#=\\s*("?)([^\n]*)\\1+\\s*#i', + ]; + } + + /** + * Return capture group to access the password + * + * @return array + */ + public function indexes(): array + { + return [2]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Json.php b/lib/captainhook/secrets/src/Regex/Supplier/Json.php new file mode 100644 index 0000000000..beb39e0ab3 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Json.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Grouped; + +/** + * Find any possible string assignment in a json file + * + * Finds: + * - "foo": "string" + */ +class Json implements Grouped +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + // detecting any string assignment + '#:\\s*' . Util::QUOTE . '(.*?)' . Util::QUOTE . '#i', + ]; + } + + /** + * Return capture group to access the password + * + * @return array + */ + public function indexes(): array + { + return [2]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/PHP.php b/lib/captainhook/secrets/src/Regex/Supplier/PHP.php new file mode 100644 index 0000000000..48eacaa276 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/PHP.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Grouped; + +/** + * Find any possible string assignment in a php file + * + * Finds: + * - $foo = "string" + * - $foo = ["foo" => "string"] + */ +class PHP implements Grouped +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + // detecting any string assignment + // = "some string", => 'some-string' return 'some-string + '#(=>?|return)\\s*' . Util::QUOTE . '(.*?)' . Util::QUOTE . '#i', + ]; + } + + /** + * Return capture group to access the password + * + * @return array + */ + public function indexes(): array + { + return [3]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Password.php b/lib/captainhook/secrets/src/Regex/Supplier/Password.php new file mode 100644 index 0000000000..848390d3b9 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Password.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Supplier; + +/** + * Password regex supplier + * + * Provides the regex to find generic passwords. + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class Password implements Supplier +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + // Generic passwords + '#password' . Util::OPTIONAL_QUOTE . Util::CONNECT . Util::OPTIONAL_QUOTE + . '([a-z\\-_\\#/\\+0-9]{16,})' . Util::OPTIONAL_QUOTE . '#i', + ]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Stripe.php b/lib/captainhook/secrets/src/Regex/Supplier/Stripe.php new file mode 100644 index 0000000000..e705fff3a1 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Stripe.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Supplier; + +/** + * Stripe regex supplier + * + * Provides the regex to find Stripe secrets. + * + * @package CaptainHook + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class Stripe implements Supplier +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + // Standard API Key & Restricted API Key + '#' . Util::OPTIONAL_QUOTE . '(sk_live_[0-9a-z]{24})' . Util::OPTIONAL_QUOTE . '#', + ]; + } +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Util.php b/lib/captainhook/secrets/src/Regex/Supplier/Util.php new file mode 100644 index 0000000000..f39c445922 --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Util.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets\Regex\Supplier; + +/** + * Regex utility constants and functions + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +abstract class Util +{ + public const QUOTE = '("|\')'; + public const OPTIONAL_QUOTE = self::QUOTE . '?'; + public const CONNECT = '\s*(:|=>|=|:=)\s*'; +} diff --git a/lib/captainhook/secrets/src/Regex/Supplier/Yaml.php b/lib/captainhook/secrets/src/Regex/Supplier/Yaml.php new file mode 100644 index 0000000000..15d857ac0e --- /dev/null +++ b/lib/captainhook/secrets/src/Regex/Supplier/Yaml.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\Secrets\Regex\Supplier; + +use CaptainHook\Secrets\Regex\Grouped; + +/** + * Find any possible string assignment in a yaml file + * + * Finds: + * - foo: "string" + * - foo: string + */ +class Yaml implements Grouped +{ + /** + * Returns a list of patterns to check + * + * @return array + */ + public function patterns(): array + { + return [ + '#: ("|\'?)([^\n]*)\\1+#i', + ]; + } + + /** + * Return capture group to access the password + * + * @return array + */ + public function indexes(): array + { + return [2]; + } +} diff --git a/lib/captainhook/secrets/src/Regexer.php b/lib/captainhook/secrets/src/Regexer.php new file mode 100644 index 0000000000..3b31c01557 --- /dev/null +++ b/lib/captainhook/secrets/src/Regexer.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CaptainHook\Secrets; + +use CaptainHook\Secrets\Regex\Grouped; + +class Regexer +{ + /** + * @var \CaptainHook\Secrets\Regex\Grouped + */ + private Grouped $supplier; + + /** + * Creates a new Detector + * + * @return \CaptainHook\Secrets\Regexer + */ + public static function create(): self + { + return new self(); + } + + /** + * The Regexer can only deal with Grouped Suppliers + * Those Suppliers provide the index of the detection group where to find the potential password. + * + * @param \CaptainHook\Secrets\Regex\Grouped $supplier + * @return $this + */ + public function useGroupedSupplier(Grouped $supplier): self + { + $this->supplier = $supplier; + return $this; + } + + /** + * Returns a lost of all potential passwords + * + * @param string $text + * @return \CaptainHook\Secrets\Result + */ + public function detectIn(string $text): Result + { + $allMatches = []; + foreach ($this->supplier->patterns() as $num => $regex) { + $matches = []; + if (preg_match($regex, $text, $matches)) { + $allMatches[] = $matches[$this->supplier->indexes()[$num]]; + } + } + return new Result($allMatches); + } +} diff --git a/lib/captainhook/secrets/src/Result.php b/lib/captainhook/secrets/src/Result.php new file mode 100644 index 0000000000..dbae69aa96 --- /dev/null +++ b/lib/captainhook/secrets/src/Result.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace CaptainHook\Secrets; + +/** + * Result + * + * Holds all information about the executed detection + * + * @package CaptainHook-Secrets + * @author Sebastian Feldmann + * @link https://github.com/captainhook-git/secrets + * @since Class available since Release 0.9.1 + */ +class Result +{ + /** + * List of found secrets + * + * @var array + */ + private array $matches; + + /** + * @param array $matches + */ + public function __construct(array $matches) + { + $this->matches = $matches; + } + + /** + * Returns true if a secret was found + * + * @return bool + */ + public function wasSecretDetected(): bool + { + return count($this->matches) > 0; + } + + /** + * Returns a list of all matches found during detection + * + * @return array + */ + public function matches(): array + { + return $this->matches; + } +} diff --git a/lib/composer/InstalledVersions.php b/lib/composer/InstalledVersions.php index 2052022fd8..e24f5061ed 100644 --- a/lib/composer/InstalledVersions.php +++ b/lib/composer/InstalledVersions.php @@ -26,371 +26,353 @@ */ class InstalledVersions { - /** - * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to - * @internal - */ - private static $selfDir = null; - - /** - * @var mixed[]|null - * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null - */ - private static $installed; - - /** - * @var bool - */ - private static $installedIsLocalDir; - - /** - * @var bool|null - */ - private static $canGetVendors; - - /** - * @var array[] - * @psalm-var array}> - */ - private static $installedByVendor = array(); - - /** - * Returns a list of all package names which are present, either by being installed, replaced or provided - * - * @return string[] - * @psalm-return list - */ - public static function getInstalledPackages() - { - $packages = array(); - foreach (self::getInstalled() as $installed) { - $packages[] = array_keys($installed['versions']); - } - - if (1 === \count($packages)) { - return $packages[0]; - } - - return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); - } - - /** - * Returns a list of all package names with a specific type e.g. 'library' - * - * @param string $type - * @return string[] - * @psalm-return list - */ - public static function getInstalledPackagesByType($type) - { - $packagesByType = array(); - - foreach (self::getInstalled() as $installed) { - foreach ($installed['versions'] as $name => $package) { - if (isset($package['type']) && $package['type'] === $type) { - $packagesByType[] = $name; - } - } - } - - return $packagesByType; - } - - /** - * Checks whether the given package is installed - * - * This also returns true if the package name is provided or replaced by another package - * - * @param string $packageName - * @param bool $includeDevRequirements - * @return bool - */ - public static function isInstalled($packageName, $includeDevRequirements = true) - { - foreach (self::getInstalled() as $installed) { - if (isset($installed['versions'][$packageName])) { - return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; - } - } - - return false; - } - - /** - * Checks whether the given package satisfies a version constraint - * - * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: - * - * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') - * - * @param VersionParser $parser Install composer/semver to have access to this class and functionality - * @param string $packageName - * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package - * @return bool - */ - public static function satisfies(VersionParser $parser, $packageName, $constraint) - { - $constraint = $parser->parseConstraints((string) $constraint); - $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); - - return $provided->matches($constraint); - } - - /** - * Returns a version constraint representing all the range(s) which are installed for a given package - * - * It is easier to use this via isInstalled() with the $constraint argument if you need to check - * whether a given version of a package is installed, and not just whether it exists - * - * @param string $packageName - * @return string Version constraint usable with composer/semver - */ - public static function getVersionRanges($packageName) - { - foreach (self::getInstalled() as $installed) { - if (!isset($installed['versions'][$packageName])) { - continue; - } - - $ranges = array(); - if (isset($installed['versions'][$packageName]['pretty_version'])) { - $ranges[] = $installed['versions'][$packageName]['pretty_version']; - } - if (array_key_exists('aliases', $installed['versions'][$packageName])) { - $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); - } - if (array_key_exists('replaced', $installed['versions'][$packageName])) { - $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); - } - if (array_key_exists('provided', $installed['versions'][$packageName])) { - $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); - } - - return implode(' || ', $ranges); - } - - throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); - } - - /** - * @param string $packageName - * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present - */ - public static function getVersion($packageName) - { - foreach (self::getInstalled() as $installed) { - if (!isset($installed['versions'][$packageName])) { - continue; - } - - if (!isset($installed['versions'][$packageName]['version'])) { - return null; - } - - return $installed['versions'][$packageName]['version']; - } - - throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); - } - - /** - * @param string $packageName - * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present - */ - public static function getPrettyVersion($packageName) - { - foreach (self::getInstalled() as $installed) { - if (!isset($installed['versions'][$packageName])) { - continue; - } - - if (!isset($installed['versions'][$packageName]['pretty_version'])) { - return null; - } - - return $installed['versions'][$packageName]['pretty_version']; - } - - throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); - } - - /** - * @param string $packageName - * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference - */ - public static function getReference($packageName) - { - foreach (self::getInstalled() as $installed) { - if (!isset($installed['versions'][$packageName])) { - continue; - } - - if (!isset($installed['versions'][$packageName]['reference'])) { - return null; - } - - return $installed['versions'][$packageName]['reference']; - } - - throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); - } - - /** - * @param string $packageName - * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. - */ - public static function getInstallPath($packageName) - { - foreach (self::getInstalled() as $installed) { - if (!isset($installed['versions'][$packageName])) { - continue; - } - - return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; - } - - throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); - } - - /** - * @return array - * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} - */ - public static function getRootPackage() - { - $installed = self::getInstalled(); - - return $installed[0]['root']; - } - - /** - * Returns the raw installed.php data for custom implementations - * - * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. - * @return array[] - * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} - */ - public static function getRawData() - { - @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); - - if (null === self::$installed) { - // only require the installed.php file if this file is loaded from its dumped location, - // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 - if (substr(__DIR__, -8, 1) !== 'C') { - self::$installed = include __DIR__ . '/installed.php'; - } else { - self::$installed = array(); - } - } - - return self::$installed; - } - - /** - * Returns the raw data of all installed.php which are currently loaded for custom implementations - * - * @return array[] - * @psalm-return list}> - */ - public static function getAllRawData() - { - return self::getInstalled(); - } - - /** - * Lets you reload the static array from another file - * - * This is only useful for complex integrations in which a project needs to use - * this class but then also needs to execute another project's autoloader in process, - * and wants to ensure both projects have access to their version of installed.php. - * - * A typical case would be PHPUnit, where it would need to make sure it reads all - * the data it needs from this class, then call reload() with - * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure - * the project in which it runs can then also use this class safely, without - * interference between PHPUnit's dependencies and the project's dependencies. - * - * @param array[] $data A vendor/composer/installed.php data set - * @return void - * - * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data - */ - public static function reload($data) - { - self::$installed = $data; - self::$installedByVendor = array(); - - // when using reload, we disable the duplicate protection to ensure that self::$installed data is - // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, - // so we have to assume it does not, and that may result in duplicate data being returned when listing - // all installed packages for example - self::$installedIsLocalDir = false; - } - - /** - * @return string - */ - private static function getSelfDir() - { - if (self::$selfDir === null) { - self::$selfDir = strtr(__DIR__, '\\', '/'); - } - - return self::$selfDir; - } - - /** - * @return array[] - * @psalm-return list}> - */ - private static function getInstalled() - { - if (null === self::$canGetVendors) { - self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); - } - - $installed = array(); - $copiedLocalDir = false; - - if (self::$canGetVendors) { - $selfDir = self::getSelfDir(); - foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { - $vendorDir = strtr($vendorDir, '\\', '/'); - if (isset(self::$installedByVendor[$vendorDir])) { - $installed[] = self::$installedByVendor[$vendorDir]; - } elseif (is_file($vendorDir.'/composer/installed.php')) { - /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ - $required = require $vendorDir.'/composer/installed.php'; - self::$installedByVendor[$vendorDir] = $required; - $installed[] = $required; - if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { - self::$installed = $required; - self::$installedIsLocalDir = true; - } - } - if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { - $copiedLocalDir = true; - } - } - } - - if (null === self::$installed) { - // only require the installed.php file if this file is loaded from its dumped location, - // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 - if (substr(__DIR__, -8, 1) !== 'C') { - /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ - $required = require __DIR__ . '/installed.php'; - self::$installed = $required; - } else { - self::$installed = array(); - } - } - - if (self::$installed !== array() && !$copiedLocalDir) { - $installed[] = self::$installed; - } - - return $installed; - } + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool + */ + private static $installedIsLocalDir; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = []; + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = []; + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = []; + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = []; + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "'.$packageName.'" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "'.$packageName.'" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "'.$packageName.'" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "'.$packageName.'" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "'.$packageName.'" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__.'/installed.php'; + } else { + self::$installed = []; + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = []; + + // when using reload, we disable the duplicate protection to ensure that self::$installed data is + // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, + // so we have to assume it does not, and that may result in duplicate data being returned when listing + // all installed packages for example + self::$installedIsLocalDir = false; + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = []; + $copiedLocalDir = false; + + if (self::$canGetVendors) { + $selfDir = strtr(__DIR__, '\\', '/'); + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + $vendorDir = strtr($vendorDir, '\\', '/'); + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { + self::$installed = $required; + self::$installedIsLocalDir = true; + } + } + if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { + $copiedLocalDir = true; + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__.'/installed.php'; + self::$installed = $required; + } else { + self::$installed = []; + } + } + + if (self::$installed !== [] && !$copiedLocalDir) { + $installed[] = self::$installed; + } + + return $installed; + } } diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php index d98cfc88ed..ba6783f0f2 100644 --- a/lib/composer/autoload_classmap.php +++ b/lib/composer/autoload_classmap.php @@ -178,6 +178,235 @@ 'CSVBulkExport' => $baseDir . '/core/csvbulkexport.class.inc.php', 'CSVParser' => $baseDir . '/core/csvparser.class.inc.php', 'CSVParserException' => $baseDir . '/application/exceptions/CSVParserException.php', + 'CaptainHook\\App\\CH' => $vendorDir . '/captainhook/captainhook/src/CH.php', + 'CaptainHook\\App\\Config' => $vendorDir . '/captainhook/captainhook/src/Config.php', + 'CaptainHook\\App\\Config\\Action' => $vendorDir . '/captainhook/captainhook/src/Config/Action.php', + 'CaptainHook\\App\\Config\\Condition' => $vendorDir . '/captainhook/captainhook/src/Config/Condition.php', + 'CaptainHook\\App\\Config\\Factory' => $vendorDir . '/captainhook/captainhook/src/Config/Factory.php', + 'CaptainHook\\App\\Config\\Hook' => $vendorDir . '/captainhook/captainhook/src/Config/Hook.php', + 'CaptainHook\\App\\Config\\Options' => $vendorDir . '/captainhook/captainhook/src/Config/Options.php', + 'CaptainHook\\App\\Config\\Plugin' => $vendorDir . '/captainhook/captainhook/src/Config/Plugin.php', + 'CaptainHook\\App\\Config\\Run' => $vendorDir . '/captainhook/captainhook/src/Config/Run.php', + 'CaptainHook\\App\\Config\\Util' => $vendorDir . '/captainhook/captainhook/src/Config/Util.php', + 'CaptainHook\\App\\Console\\Application' => $vendorDir . '/captainhook/captainhook/src/Console/Application.php', + 'CaptainHook\\App\\Console\\Command' => $vendorDir . '/captainhook/captainhook/src/Console/Command.php', + 'CaptainHook\\App\\Console\\Command\\Add' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Add.php', + 'CaptainHook\\App\\Console\\Command\\ConfigAware' => $vendorDir . '/captainhook/captainhook/src/Console/Command/ConfigAware.php', + 'CaptainHook\\App\\Console\\Command\\Configuration' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Configuration.php', + 'CaptainHook\\App\\Console\\Command\\Disable' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Disable.php', + 'CaptainHook\\App\\Console\\Command\\Enable' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Enable.php', + 'CaptainHook\\App\\Console\\Command\\Hook' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\CommitMsg' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/CommitMsg.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostCheckout' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/PostCheckout.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostCommit' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/PostCommit.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostMerge' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/PostMerge.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostRewrite' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/PostRewrite.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PreCommit' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/PreCommit.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PrePush' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/PrePush.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PrepareCommitMsg' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Hook/PrepareCommitMsg.php', + 'CaptainHook\\App\\Console\\Command\\Info' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Info.php', + 'CaptainHook\\App\\Console\\Command\\Install' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Install.php', + 'CaptainHook\\App\\Console\\Command\\RepositoryAware' => $vendorDir . '/captainhook/captainhook/src/Console/Command/RepositoryAware.php', + 'CaptainHook\\App\\Console\\Command\\Uninstall' => $vendorDir . '/captainhook/captainhook/src/Console/Command/Uninstall.php', + 'CaptainHook\\App\\Console\\IO' => $vendorDir . '/captainhook/captainhook/src/Console/IO.php', + 'CaptainHook\\App\\Console\\IOUtil' => $vendorDir . '/captainhook/captainhook/src/Console/IOUtil.php', + 'CaptainHook\\App\\Console\\IO\\Base' => $vendorDir . '/captainhook/captainhook/src/Console/IO/Base.php', + 'CaptainHook\\App\\Console\\IO\\CollectorIO' => $vendorDir . '/captainhook/captainhook/src/Console/IO/CollectorIO.php', + 'CaptainHook\\App\\Console\\IO\\ComposerIO' => $vendorDir . '/captainhook/captainhook/src/Console/IO/ComposerIO.php', + 'CaptainHook\\App\\Console\\IO\\DefaultIO' => $vendorDir . '/captainhook/captainhook/src/Console/IO/DefaultIO.php', + 'CaptainHook\\App\\Console\\IO\\Message' => $vendorDir . '/captainhook/captainhook/src/Console/IO/Message.php', + 'CaptainHook\\App\\Console\\IO\\NullIO' => $vendorDir . '/captainhook/captainhook/src/Console/IO/NullIO.php', + 'CaptainHook\\App\\Console\\Runtime\\Resolver' => $vendorDir . '/captainhook/captainhook/src/Console/Runtime/Resolver.php', + 'CaptainHook\\App\\Event' => $vendorDir . '/captainhook/captainhook/src/Event.php', + 'CaptainHook\\App\\Event\\Dispatcher' => $vendorDir . '/captainhook/captainhook/src/Event/Dispatcher.php', + 'CaptainHook\\App\\Event\\Factory' => $vendorDir . '/captainhook/captainhook/src/Event/Factory.php', + 'CaptainHook\\App\\Event\\Handler' => $vendorDir . '/captainhook/captainhook/src/Event/Handler.php', + 'CaptainHook\\App\\Event\\Hook' => $vendorDir . '/captainhook/captainhook/src/Event/Hook.php', + 'CaptainHook\\App\\Event\\HookFailed' => $vendorDir . '/captainhook/captainhook/src/Event/HookFailed.php', + 'CaptainHook\\App\\Event\\HookSucceeded' => $vendorDir . '/captainhook/captainhook/src/Event/HookSucceeded.php', + 'CaptainHook\\App\\Exception\\ActionFailed' => $vendorDir . '/captainhook/captainhook/src/Exception/ActionFailed.php', + 'CaptainHook\\App\\Exception\\CaptainHookException' => $vendorDir . '/captainhook/captainhook/src/Exception/CaptainHookException.php', + 'CaptainHook\\App\\Exception\\InvalidHookName' => $vendorDir . '/captainhook/captainhook/src/Exception/InvalidHookName.php', + 'CaptainHook\\App\\Exception\\InvalidPlugin' => $vendorDir . '/captainhook/captainhook/src/Exception/InvalidPlugin.php', + 'CaptainHook\\App\\Git\\ChangedFiles' => $vendorDir . '/captainhook/captainhook/src/Git/ChangedFiles.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detecting' => $vendorDir . '/captainhook/captainhook/src/Git/ChangedFiles/Detecting.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector' => $vendorDir . '/captainhook/captainhook/src/Git/ChangedFiles/Detector.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\Factory' => $vendorDir . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/Factory.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\Fallback' => $vendorDir . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/Fallback.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\PostRewrite' => $vendorDir . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/PostRewrite.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\PrePush' => $vendorDir . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/PrePush.php', + 'CaptainHook\\App\\Git\\Diff\\FilterUtil' => $vendorDir . '/captainhook/captainhook/src/Git/Diff/FilterUtil.php', + 'CaptainHook\\App\\Git\\Range' => $vendorDir . '/captainhook/captainhook/src/Git/Range.php', + 'CaptainHook\\App\\Git\\Range\\Detecting' => $vendorDir . '/captainhook/captainhook/src/Git/Range/Detecting.php', + 'CaptainHook\\App\\Git\\Range\\Detector\\Fallback' => $vendorDir . '/captainhook/captainhook/src/Git/Range/Detector/Fallback.php', + 'CaptainHook\\App\\Git\\Range\\Detector\\PostRewrite' => $vendorDir . '/captainhook/captainhook/src/Git/Range/Detector/PostRewrite.php', + 'CaptainHook\\App\\Git\\Range\\Detector\\PrePush' => $vendorDir . '/captainhook/captainhook/src/Git/Range/Detector/PrePush.php', + 'CaptainHook\\App\\Git\\Range\\Generic' => $vendorDir . '/captainhook/captainhook/src/Git/Range/Generic.php', + 'CaptainHook\\App\\Git\\Range\\PrePush' => $vendorDir . '/captainhook/captainhook/src/Git/Range/PrePush.php', + 'CaptainHook\\App\\Git\\Rev' => $vendorDir . '/captainhook/captainhook/src/Git/Rev.php', + 'CaptainHook\\App\\Git\\Rev\\Generic' => $vendorDir . '/captainhook/captainhook/src/Git/Rev/Generic.php', + 'CaptainHook\\App\\Git\\Rev\\PrePush' => $vendorDir . '/captainhook/captainhook/src/Git/Rev/PrePush.php', + 'CaptainHook\\App\\Git\\Rev\\Util' => $vendorDir . '/captainhook/captainhook/src/Git/Rev/Util.php', + 'CaptainHook\\App\\Hook\\Action' => $vendorDir . '/captainhook/captainhook/src/Hook/Action.php', + 'CaptainHook\\App\\Hook\\Branch\\Action\\BlockFixupAndSquashCommits' => $vendorDir . '/captainhook/captainhook/src/Hook/Branch/Action/BlockFixupAndSquashCommits.php', + 'CaptainHook\\App\\Hook\\Branch\\Action\\EnsureNaming' => $vendorDir . '/captainhook/captainhook/src/Hook/Branch/Action/EnsureNaming.php', + 'CaptainHook\\App\\Hook\\Cli\\Command' => $vendorDir . '/captainhook/captainhook/src/Hook/Cli/Command.php', + 'CaptainHook\\App\\Hook\\Composer\\Action\\CheckLockFile' => $vendorDir . '/captainhook/captainhook/src/Hook/Composer/Action/CheckLockFile.php', + 'CaptainHook\\App\\Hook\\Condition' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\Files' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Branch/Files.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\Name' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Branch/Name.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\NotOn' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Branch/NotOn.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\NotOnMatching' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Branch/NotOnMatching.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\On' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Branch/On.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\OnMatching' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Branch/OnMatching.php', + 'CaptainHook\\App\\Hook\\Condition\\Cli' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Cli.php', + 'CaptainHook\\App\\Hook\\Condition\\Config' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Config.php', + 'CaptainHook\\App\\Hook\\Condition\\ConfigDependant' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/ConfigDependant.php', + 'CaptainHook\\App\\Hook\\Condition\\Config\\CustomValueIsFalsy' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsFalsy.php', + 'CaptainHook\\App\\Hook\\Condition\\Config\\CustomValueIsTruthy' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsTruthy.php', + 'CaptainHook\\App\\Hook\\Condition\\File' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/File.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileChanged.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged\\All' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileChanged/All.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged\\Any' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileChanged/Any.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged\\OfType' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileChanged/OfType.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileStaged.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\All' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileStaged/All.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\Any' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileStaged/Any.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\InDirectory' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileStaged/InDirectory.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\OfType' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileStaged/OfType.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\ThatIs' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/FileStaged/ThatIs.php', + 'CaptainHook\\App\\Hook\\Condition\\Logic' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Logic.php', + 'CaptainHook\\App\\Hook\\Condition\\Logic\\LogicAnd' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Logic/LogicAnd.php', + 'CaptainHook\\App\\Hook\\Condition\\Logic\\LogicOr' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/Logic/LogicOr.php', + 'CaptainHook\\App\\Hook\\Condition\\OnBranch' => $vendorDir . '/captainhook/captainhook/src/Hook/Condition/OnBranch.php', + 'CaptainHook\\App\\Hook\\Constrained' => $vendorDir . '/captainhook/captainhook/src/Hook/Constrained.php', + 'CaptainHook\\App\\Hook\\Debug' => $vendorDir . '/captainhook/captainhook/src/Hook/Debug.php', + 'CaptainHook\\App\\Hook\\Debug\\Failure' => $vendorDir . '/captainhook/captainhook/src/Hook/Debug/Failure.php', + 'CaptainHook\\App\\Hook\\Debug\\Success' => $vendorDir . '/captainhook/captainhook/src/Hook/Debug/Success.php', + 'CaptainHook\\App\\Hook\\Diff\\Action\\BlockSecrets' => $vendorDir . '/captainhook/captainhook/src/Hook/Diff/Action/BlockSecrets.php', + 'CaptainHook\\App\\Hook\\EventSubscriber' => $vendorDir . '/captainhook/captainhook/src/Hook/EventSubscriber.php', + 'CaptainHook\\App\\Hook\\FileList' => $vendorDir . '/captainhook/captainhook/src/Hook/FileList.php', + 'CaptainHook\\App\\Hook\\File\\Action\\Check' => $vendorDir . '/captainhook/captainhook/src/Hook/File/Action/Check.php', + 'CaptainHook\\App\\Hook\\File\\Action\\DoesNotContainRegex' => $vendorDir . '/captainhook/captainhook/src/Hook/File/Action/DoesNotContainRegex.php', + 'CaptainHook\\App\\Hook\\File\\Action\\Emptiness' => $vendorDir . '/captainhook/captainhook/src/Hook/File/Action/Emptiness.php', + 'CaptainHook\\App\\Hook\\File\\Action\\Exists' => $vendorDir . '/captainhook/captainhook/src/Hook/File/Action/Exists.php', + 'CaptainHook\\App\\Hook\\File\\Action\\IsEmpty' => $vendorDir . '/captainhook/captainhook/src/Hook/File/Action/IsEmpty.php', + 'CaptainHook\\App\\Hook\\File\\Action\\IsNotEmpty' => $vendorDir . '/captainhook/captainhook/src/Hook/File/Action/IsNotEmpty.php', + 'CaptainHook\\App\\Hook\\File\\Action\\MaxSize' => $vendorDir . '/captainhook/captainhook/src/Hook/File/Action/MaxSize.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Beams' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/Beams.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Book' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/Book.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\CacheOnFail' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/CacheOnFail.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\InjectIssueKeyFromBranch' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/InjectIssueKeyFromBranch.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Prepare' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/Prepare.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\PrepareFromFile' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/PrepareFromFile.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Regex' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/Regex.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Rules' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Action/Rules.php', + 'CaptainHook\\App\\Hook\\Message\\EventHandler\\WriteCacheFile' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/EventHandler/WriteCacheFile.php', + 'CaptainHook\\App\\Hook\\Message\\Rule' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule.php', + 'CaptainHook\\App\\Hook\\Message\\RuleBook' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/RuleBook.php', + 'CaptainHook\\App\\Hook\\Message\\RuleBook\\RuleSet' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/RuleBook/RuleSet.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\Base' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/Base.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\Blacklist' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/Blacklist.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\CapitalizeSubject' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/CapitalizeSubject.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\LimitBodyLineLength' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/LimitBodyLineLength.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\LimitSubjectLength' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/LimitSubjectLength.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\MsgNotEmpty' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/MsgNotEmpty.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\NoPeriodOnSubjectEnd' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/NoPeriodOnSubjectEnd.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\SeparateSubjectFromBodyWithBlankLine' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/SeparateSubjectFromBodyWithBlankLine.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\UseImperativeMood' => $vendorDir . '/captainhook/captainhook/src/Hook/Message/Rule/UseImperativeMood.php', + 'CaptainHook\\App\\Hook\\Notify\\Action\\IntegrateBeforePush' => $vendorDir . '/captainhook/captainhook/src/Hook/Notify/Action/IntegrateBeforePush.php', + 'CaptainHook\\App\\Hook\\Notify\\Action\\Notify' => $vendorDir . '/captainhook/captainhook/src/Hook/Notify/Action/Notify.php', + 'CaptainHook\\App\\Hook\\Notify\\Extractor' => $vendorDir . '/captainhook/captainhook/src/Hook/Notify/Extractor.php', + 'CaptainHook\\App\\Hook\\Notify\\Notification' => $vendorDir . '/captainhook/captainhook/src/Hook/Notify/Notification.php', + 'CaptainHook\\App\\Hook\\PHP\\Action\\Linting' => $vendorDir . '/captainhook/captainhook/src/Hook/PHP/Action/Linting.php', + 'CaptainHook\\App\\Hook\\PHP\\Action\\TestCoverage' => $vendorDir . '/captainhook/captainhook/src/Hook/PHP/Action/TestCoverage.php', + 'CaptainHook\\App\\Hook\\PHP\\CoverageResolver' => $vendorDir . '/captainhook/captainhook/src/Hook/PHP/CoverageResolver.php', + 'CaptainHook\\App\\Hook\\PHP\\CoverageResolver\\CloverXML' => $vendorDir . '/captainhook/captainhook/src/Hook/PHP/CoverageResolver/CloverXML.php', + 'CaptainHook\\App\\Hook\\PHP\\CoverageResolver\\PHPUnit' => $vendorDir . '/captainhook/captainhook/src/Hook/PHP/CoverageResolver/PHPUnit.php', + 'CaptainHook\\App\\Hook\\Restriction' => $vendorDir . '/captainhook/captainhook/src/Hook/Restriction.php', + 'CaptainHook\\App\\Hook\\Template' => $vendorDir . '/captainhook/captainhook/src/Hook/Template.php', + 'CaptainHook\\App\\Hook\\Template\\Builder' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/Builder.php', + 'CaptainHook\\App\\Hook\\Template\\Docker' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/Docker.php', + 'CaptainHook\\App\\Hook\\Template\\Inspector' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/Inspector.php', + 'CaptainHook\\App\\Hook\\Template\\Local' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/Local.php', + 'CaptainHook\\App\\Hook\\Template\\Local\\PHP' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/Local/PHP.php', + 'CaptainHook\\App\\Hook\\Template\\Local\\Shell' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/Local/Shell.php', + 'CaptainHook\\App\\Hook\\Template\\Local\\WSL' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/Local/WSL.php', + 'CaptainHook\\App\\Hook\\Template\\PathInfo' => $vendorDir . '/captainhook/captainhook/src/Hook/Template/PathInfo.php', + 'CaptainHook\\App\\Hook\\UserInput\\AskConfirmation' => $vendorDir . '/captainhook/captainhook/src/Hook/UserInput/AskConfirmation.php', + 'CaptainHook\\App\\Hook\\UserInput\\EventHandler\\AskConfirmation' => $vendorDir . '/captainhook/captainhook/src/Hook/UserInput/EventHandler/AskConfirmation.php', + 'CaptainHook\\App\\Hook\\Util' => $vendorDir . '/captainhook/captainhook/src/Hook/Util.php', + 'CaptainHook\\App\\Hooks' => $vendorDir . '/captainhook/captainhook/src/Hooks.php', + 'CaptainHook\\App\\Plugin\\CaptainHook' => $vendorDir . '/captainhook/captainhook/src/Plugin/CaptainHook.php', + 'CaptainHook\\App\\Plugin\\Hook' => $vendorDir . '/captainhook/captainhook/src/Plugin/Hook.php', + 'CaptainHook\\App\\Plugin\\Hook\\Base' => $vendorDir . '/captainhook/captainhook/src/Plugin/Hook/Base.php', + 'CaptainHook\\App\\Plugin\\Hook\\PreserveWorkingTree' => $vendorDir . '/captainhook/captainhook/src/Plugin/Hook/PreserveWorkingTree.php', + 'CaptainHook\\App\\Runner' => $vendorDir . '/captainhook/captainhook/src/Runner.php', + 'CaptainHook\\App\\Runner\\Action' => $vendorDir . '/captainhook/captainhook/src/Runner/Action.php', + 'CaptainHook\\App\\Runner\\Action\\Cli' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Formatter' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Formatter.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Arg' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Arg.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\BranchFiles' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/BranchFiles.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\ChangedFiles' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/ChangedFiles.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Config' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Config.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Env' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Env.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Foundation' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Foundation.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\StagedFiles' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StagedFiles.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\StdIn' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StdIn.php', + 'CaptainHook\\App\\Runner\\Action\\Log' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/Log.php', + 'CaptainHook\\App\\Runner\\Action\\PHP' => $vendorDir . '/captainhook/captainhook/src/Runner/Action/PHP.php', + 'CaptainHook\\App\\Runner\\Bootstrap\\Util' => $vendorDir . '/captainhook/captainhook/src/Runner/Bootstrap/Util.php', + 'CaptainHook\\App\\Runner\\Condition' => $vendorDir . '/captainhook/captainhook/src/Runner/Condition.php', + 'CaptainHook\\App\\Runner\\Config\\Change' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Change.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\AddAction' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Change/AddAction.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\DisableHook' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Change/DisableHook.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\EnableHook' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Change/EnableHook.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\Hook' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Change/Hook.php', + 'CaptainHook\\App\\Runner\\Config\\Creator' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Creator.php', + 'CaptainHook\\App\\Runner\\Config\\Editor' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Editor.php', + 'CaptainHook\\App\\Runner\\Config\\Reader' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Reader.php', + 'CaptainHook\\App\\Runner\\Config\\Setup' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Setup.php', + 'CaptainHook\\App\\Runner\\Config\\Setup\\Advanced' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Setup/Advanced.php', + 'CaptainHook\\App\\Runner\\Config\\Setup\\Express' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Setup/Express.php', + 'CaptainHook\\App\\Runner\\Config\\Setup\\Guided' => $vendorDir . '/captainhook/captainhook/src/Runner/Config/Setup/Guided.php', + 'CaptainHook\\App\\Runner\\Files' => $vendorDir . '/captainhook/captainhook/src/Runner/Files.php', + 'CaptainHook\\App\\Runner\\Hook' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook.php', + 'CaptainHook\\App\\Runner\\Hook\\Arg' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/Arg.php', + 'CaptainHook\\App\\Runner\\Hook\\CommitMsg' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/CommitMsg.php', + 'CaptainHook\\App\\Runner\\Hook\\Log' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/Log.php', + 'CaptainHook\\App\\Runner\\Hook\\PostCheckout' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/PostCheckout.php', + 'CaptainHook\\App\\Runner\\Hook\\PostCommit' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/PostCommit.php', + 'CaptainHook\\App\\Runner\\Hook\\PostMerge' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/PostMerge.php', + 'CaptainHook\\App\\Runner\\Hook\\PostRewrite' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/PostRewrite.php', + 'CaptainHook\\App\\Runner\\Hook\\PreCommit' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/PreCommit.php', + 'CaptainHook\\App\\Runner\\Hook\\PrePush' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/PrePush.php', + 'CaptainHook\\App\\Runner\\Hook\\PrepareCommitMsg' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/PrepareCommitMsg.php', + 'CaptainHook\\App\\Runner\\Hook\\Printer' => $vendorDir . '/captainhook/captainhook/src/Runner/Hook/Printer.php', + 'CaptainHook\\App\\Runner\\Installer' => $vendorDir . '/captainhook/captainhook/src/Runner/Installer.php', + 'CaptainHook\\App\\Runner\\RepositoryAware' => $vendorDir . '/captainhook/captainhook/src/Runner/RepositoryAware.php', + 'CaptainHook\\App\\Runner\\Uninstaller' => $vendorDir . '/captainhook/captainhook/src/Runner/Uninstaller.php', + 'CaptainHook\\App\\Runner\\Util' => $vendorDir . '/captainhook/captainhook/src/Runner/Util.php', + 'CaptainHook\\App\\Storage\\File' => $vendorDir . '/captainhook/captainhook/src/Storage/File.php', + 'CaptainHook\\App\\Storage\\File\\Json' => $vendorDir . '/captainhook/captainhook/src/Storage/File/Json.php', + 'CaptainHook\\App\\Storage\\File\\Xml' => $vendorDir . '/captainhook/captainhook/src/Storage/File/Xml.php', + 'CaptainHook\\Secrets\\Detector' => $vendorDir . '/captainhook/secrets/src/Detector.php', + 'CaptainHook\\Secrets\\Entropy\\Shannon' => $vendorDir . '/captainhook/secrets/src/Entropy/Shannon.php', + 'CaptainHook\\Secrets\\Regex\\Grouped' => $vendorDir . '/captainhook/secrets/src/Regex/Grouped.php', + 'CaptainHook\\Secrets\\Regex\\Supplier' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Aws' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Aws.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\GitHub' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/GitHub.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Gitlab' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Gitlab.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Google' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Google.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Ini' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Ini.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Json' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Json.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\PHP' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/PHP.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Password' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Password.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Stripe' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Stripe.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Util' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Util.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Yaml' => $vendorDir . '/captainhook/secrets/src/Regex/Supplier/Yaml.php', + 'CaptainHook\\Secrets\\Regexer' => $vendorDir . '/captainhook/secrets/src/Regexer.php', + 'CaptainHook\\Secrets\\Result' => $vendorDir . '/captainhook/secrets/src/Result.php', 'CellChangeSpec' => $baseDir . '/core/bulkchange.class.inc.php', 'CellStatus_Ambiguous' => $baseDir . '/core/bulkchange.class.inc.php', 'CellStatus_Issue' => $baseDir . '/core/bulkchange.class.inc.php', @@ -1398,6 +1627,83 @@ 'ScssPhp\\ScssPhp\\Version' => $vendorDir . '/scssphp/scssphp/src/Version.php', 'ScssPhp\\ScssPhp\\Warn' => $vendorDir . '/scssphp/scssphp/src/Warn.php', 'SearchMenuNode' => $baseDir . '/application/menunode.class.inc.php', + 'SebastianFeldmann\\Camino\\Check' => $vendorDir . '/sebastianfeldmann/camino/src/Check.php', + 'SebastianFeldmann\\Camino\\Path' => $vendorDir . '/sebastianfeldmann/camino/src/Path.php', + 'SebastianFeldmann\\Camino\\Path\\Base' => $vendorDir . '/sebastianfeldmann/camino/src/Path/Base.php', + 'SebastianFeldmann\\Camino\\Path\\Directory' => $vendorDir . '/sebastianfeldmann/camino/src/Path/Directory.php', + 'SebastianFeldmann\\Camino\\Path\\File' => $vendorDir . '/sebastianfeldmann/camino/src/Path/File.php', + 'SebastianFeldmann\\Cli\\Command' => $vendorDir . '/sebastianfeldmann/cli/src/Command.php', + 'SebastianFeldmann\\Cli\\CommandLine' => $vendorDir . '/sebastianfeldmann/cli/src/CommandLine.php', + 'SebastianFeldmann\\Cli\\Command\\Executable' => $vendorDir . '/sebastianfeldmann/cli/src/Command/Executable.php', + 'SebastianFeldmann\\Cli\\Command\\OutputFormatter' => $vendorDir . '/sebastianfeldmann/cli/src/Command/OutputFormatter.php', + 'SebastianFeldmann\\Cli\\Command\\Result' => $vendorDir . '/sebastianfeldmann/cli/src/Command/Result.php', + 'SebastianFeldmann\\Cli\\Command\\Runner' => $vendorDir . '/sebastianfeldmann/cli/src/Command/Runner.php', + 'SebastianFeldmann\\Cli\\Command\\Runner\\Result' => $vendorDir . '/sebastianfeldmann/cli/src/Command/Runner/Result.php', + 'SebastianFeldmann\\Cli\\Command\\Runner\\Simple' => $vendorDir . '/sebastianfeldmann/cli/src/Command/Runner/Simple.php', + 'SebastianFeldmann\\Cli\\Output\\Util' => $vendorDir . '/sebastianfeldmann/cli/src/Output/Util.php', + 'SebastianFeldmann\\Cli\\Processor' => $vendorDir . '/sebastianfeldmann/cli/src/Processor.php', + 'SebastianFeldmann\\Cli\\Processor\\ProcOpen' => $vendorDir . '/sebastianfeldmann/cli/src/Processor/ProcOpen.php', + 'SebastianFeldmann\\Cli\\Processor\\Symfony' => $vendorDir . '/sebastianfeldmann/cli/src/Processor/Symfony.php', + 'SebastianFeldmann\\Cli\\Reader' => $vendorDir . '/sebastianfeldmann/cli/src/Reader.php', + 'SebastianFeldmann\\Cli\\Reader\\Abstraction' => $vendorDir . '/sebastianfeldmann/cli/src/Reader/Abstraction.php', + 'SebastianFeldmann\\Cli\\Reader\\StandardInput' => $vendorDir . '/sebastianfeldmann/cli/src/Reader/StandardInput.php', + 'SebastianFeldmann\\Cli\\Util' => $vendorDir . '/sebastianfeldmann/cli/src/Util.php', + 'SebastianFeldmann\\Git\\Command\\Add\\AddFiles' => $vendorDir . '/sebastianfeldmann/git/src/Command/Add/AddFiles.php', + 'SebastianFeldmann\\Git\\Command\\Apply\\ApplyPatch' => $vendorDir . '/sebastianfeldmann/git/src/Command/Apply/ApplyPatch.php', + 'SebastianFeldmann\\Git\\Command\\Base' => $vendorDir . '/sebastianfeldmann/git/src/Command/Base.php', + 'SebastianFeldmann\\Git\\Command\\Branch\\ListRemote' => $vendorDir . '/sebastianfeldmann/git/src/Command/Branch/ListRemote.php', + 'SebastianFeldmann\\Git\\Command\\Checkout\\RestoreWorkingTree' => $vendorDir . '/sebastianfeldmann/git/src/Command/Checkout/RestoreWorkingTree.php', + 'SebastianFeldmann\\Git\\Command\\CloneCmd\\CloneCmd' => $vendorDir . '/sebastianfeldmann/git/src/Command/CloneCmd/CloneCmd.php', + 'SebastianFeldmann\\Git\\Command\\Config\\Get' => $vendorDir . '/sebastianfeldmann/git/src/Command/Config/Get.php', + 'SebastianFeldmann\\Git\\Command\\Config\\GetVar' => $vendorDir . '/sebastianfeldmann/git/src/Command/Config/GetVar.php', + 'SebastianFeldmann\\Git\\Command\\Config\\ListSettings' => $vendorDir . '/sebastianfeldmann/git/src/Command/Config/ListSettings.php', + 'SebastianFeldmann\\Git\\Command\\Config\\ListVars' => $vendorDir . '/sebastianfeldmann/git/src/Command/Config/ListVars.php', + 'SebastianFeldmann\\Git\\Command\\Config\\MapValues' => $vendorDir . '/sebastianfeldmann/git/src/Command/Config/MapValues.php', + 'SebastianFeldmann\\Git\\Command\\Describe\\GetCurrentTag' => $vendorDir . '/sebastianfeldmann/git/src/Command/Describe/GetCurrentTag.php', + 'SebastianFeldmann\\Git\\Command\\Describe\\GetMostRecentTag' => $vendorDir . '/sebastianfeldmann/git/src/Command/Describe/GetMostRecentTag.php', + 'SebastianFeldmann\\Git\\Command\\DiffIndex\\GetStagedFiles' => $vendorDir . '/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles.php', + 'SebastianFeldmann\\Git\\Command\\DiffIndex\\GetStagedFiles\\FilterByStatus' => $vendorDir . '/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles/FilterByStatus.php', + 'SebastianFeldmann\\Git\\Command\\DiffIndex\\GetUnstagedPatch' => $vendorDir . '/sebastianfeldmann/git/src/Command/DiffIndex/GetUnstagedPatch.php', + 'SebastianFeldmann\\Git\\Command\\DiffTree\\ChangedFiles' => $vendorDir . '/sebastianfeldmann/git/src/Command/DiffTree/ChangedFiles.php', + 'SebastianFeldmann\\Git\\Command\\Diff\\ChangedFiles' => $vendorDir . '/sebastianfeldmann/git/src/Command/Diff/ChangedFiles.php', + 'SebastianFeldmann\\Git\\Command\\Diff\\Compare' => $vendorDir . '/sebastianfeldmann/git/src/Command/Diff/Compare.php', + 'SebastianFeldmann\\Git\\Command\\Diff\\Compare\\FullDiffList' => $vendorDir . '/sebastianfeldmann/git/src/Command/Diff/Compare/FullDiffList.php', + 'SebastianFeldmann\\Git\\Command\\Fetch\\Fetch' => $vendorDir . '/sebastianfeldmann/git/src/Command/Fetch/Fetch.php', + 'SebastianFeldmann\\Git\\Command\\Log\\ChangedFiles' => $vendorDir . '/sebastianfeldmann/git/src/Command/Log/ChangedFiles.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Commits' => $vendorDir . '/sebastianfeldmann/git/src/Command/Log/Commits.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Commits\\Jsonized' => $vendorDir . '/sebastianfeldmann/git/src/Command/Log/Commits/Jsonized.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Commits\\Xml' => $vendorDir . '/sebastianfeldmann/git/src/Command/Log/Commits/Xml.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Log' => $vendorDir . '/sebastianfeldmann/git/src/Command/Log/Log.php', + 'SebastianFeldmann\\Git\\Command\\LsTree\\GetFiles' => $vendorDir . '/sebastianfeldmann/git/src/Command/LsTree/GetFiles.php', + 'SebastianFeldmann\\Git\\Command\\MergeBase\\MergeBase' => $vendorDir . '/sebastianfeldmann/git/src/Command/MergeBase/MergeBase.php', + 'SebastianFeldmann\\Git\\Command\\Output\\Exploded' => $vendorDir . '/sebastianfeldmann/git/src/Command/Output/Exploded.php', + 'SebastianFeldmann\\Git\\Command\\Pull\\Pull' => $vendorDir . '/sebastianfeldmann/git/src/Command/Pull/Pull.php', + 'SebastianFeldmann\\Git\\Command\\RefLog\\BranchRevs' => $vendorDir . '/sebastianfeldmann/git/src/Command/RefLog/BranchRevs.php', + 'SebastianFeldmann\\Git\\Command\\RevParse\\GetBranch' => $vendorDir . '/sebastianfeldmann/git/src/Command/RevParse/GetBranch.php', + 'SebastianFeldmann\\Git\\Command\\RevParse\\GetCommitHash' => $vendorDir . '/sebastianfeldmann/git/src/Command/RevParse/GetCommitHash.php', + 'SebastianFeldmann\\Git\\Command\\Rm\\RemoveFiles' => $vendorDir . '/sebastianfeldmann/git/src/Command/Rm/RemoveFiles.php', + 'SebastianFeldmann\\Git\\Command\\Status\\Porcelain\\PathList' => $vendorDir . '/sebastianfeldmann/git/src/Command/Status/Porcelain/PathList.php', + 'SebastianFeldmann\\Git\\Command\\Status\\WorkingTreeStatus' => $vendorDir . '/sebastianfeldmann/git/src/Command/Status/WorkingTreeStatus.php', + 'SebastianFeldmann\\Git\\Command\\Tag\\GetTags' => $vendorDir . '/sebastianfeldmann/git/src/Command/Tag/GetTags.php', + 'SebastianFeldmann\\Git\\Command\\WriteTree\\CreateTreeObject' => $vendorDir . '/sebastianfeldmann/git/src/Command/WriteTree/CreateTreeObject.php', + 'SebastianFeldmann\\Git\\CommitMessage' => $vendorDir . '/sebastianfeldmann/git/src/CommitMessage.php', + 'SebastianFeldmann\\Git\\Diff\\Change' => $vendorDir . '/sebastianfeldmann/git/src/Diff/Change.php', + 'SebastianFeldmann\\Git\\Diff\\File' => $vendorDir . '/sebastianfeldmann/git/src/Diff/File.php', + 'SebastianFeldmann\\Git\\Diff\\FilterUtil' => $vendorDir . '/sebastianfeldmann/git/src/Diff/FilterUtil.php', + 'SebastianFeldmann\\Git\\Diff\\Line' => $vendorDir . '/sebastianfeldmann/git/src/Diff/Line.php', + 'SebastianFeldmann\\Git\\Log\\Commit' => $vendorDir . '/sebastianfeldmann/git/src/Log/Commit.php', + 'SebastianFeldmann\\Git\\Operator\\Base' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Base.php', + 'SebastianFeldmann\\Git\\Operator\\Config' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Config.php', + 'SebastianFeldmann\\Git\\Operator\\Diff' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Diff.php', + 'SebastianFeldmann\\Git\\Operator\\Index' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Index.php', + 'SebastianFeldmann\\Git\\Operator\\Info' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Info.php', + 'SebastianFeldmann\\Git\\Operator\\Log' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Log.php', + 'SebastianFeldmann\\Git\\Operator\\Remote' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Remote.php', + 'SebastianFeldmann\\Git\\Operator\\Status' => $vendorDir . '/sebastianfeldmann/git/src/Operator/Status.php', + 'SebastianFeldmann\\Git\\Repository' => $vendorDir . '/sebastianfeldmann/git/src/Repository.php', + 'SebastianFeldmann\\Git\\Repository\\Cloner' => $vendorDir . '/sebastianfeldmann/git/src/Repository/Cloner.php', + 'SebastianFeldmann\\Git\\Status\\Path' => $vendorDir . '/sebastianfeldmann/git/src/Status/Path.php', + 'SebastianFeldmann\\Git\\Url' => $vendorDir . '/sebastianfeldmann/git/src/Url.php', 'SecurityException' => $baseDir . '/application/exceptions/SecurityException.php', 'SeparatorPopupMenuItem' => $baseDir . '/application/applicationextension.inc.php', 'SetupLog' => $baseDir . '/core/log.class.inc.php', @@ -2564,6 +2870,28 @@ 'Symfony\\Component\\Mime\\Part\\SMimePart' => $vendorDir . '/symfony/mime/Part/SMimePart.php', 'Symfony\\Component\\Mime\\Part\\TextPart' => $vendorDir . '/symfony/mime/Part/TextPart.php', 'Symfony\\Component\\Mime\\RawMessage' => $vendorDir . '/symfony/mime/RawMessage.php', + 'Symfony\\Component\\Process\\Exception\\ExceptionInterface' => $vendorDir . '/symfony/process/Exception/ExceptionInterface.php', + 'Symfony\\Component\\Process\\Exception\\InvalidArgumentException' => $vendorDir . '/symfony/process/Exception/InvalidArgumentException.php', + 'Symfony\\Component\\Process\\Exception\\LogicException' => $vendorDir . '/symfony/process/Exception/LogicException.php', + 'Symfony\\Component\\Process\\Exception\\ProcessFailedException' => $vendorDir . '/symfony/process/Exception/ProcessFailedException.php', + 'Symfony\\Component\\Process\\Exception\\ProcessSignaledException' => $vendorDir . '/symfony/process/Exception/ProcessSignaledException.php', + 'Symfony\\Component\\Process\\Exception\\ProcessTimedOutException' => $vendorDir . '/symfony/process/Exception/ProcessTimedOutException.php', + 'Symfony\\Component\\Process\\Exception\\RunProcessFailedException' => $vendorDir . '/symfony/process/Exception/RunProcessFailedException.php', + 'Symfony\\Component\\Process\\Exception\\RuntimeException' => $vendorDir . '/symfony/process/Exception/RuntimeException.php', + 'Symfony\\Component\\Process\\ExecutableFinder' => $vendorDir . '/symfony/process/ExecutableFinder.php', + 'Symfony\\Component\\Process\\InputStream' => $vendorDir . '/symfony/process/InputStream.php', + 'Symfony\\Component\\Process\\Messenger\\RunProcessContext' => $vendorDir . '/symfony/process/Messenger/RunProcessContext.php', + 'Symfony\\Component\\Process\\Messenger\\RunProcessMessage' => $vendorDir . '/symfony/process/Messenger/RunProcessMessage.php', + 'Symfony\\Component\\Process\\Messenger\\RunProcessMessageHandler' => $vendorDir . '/symfony/process/Messenger/RunProcessMessageHandler.php', + 'Symfony\\Component\\Process\\PhpExecutableFinder' => $vendorDir . '/symfony/process/PhpExecutableFinder.php', + 'Symfony\\Component\\Process\\PhpProcess' => $vendorDir . '/symfony/process/PhpProcess.php', + 'Symfony\\Component\\Process\\PhpSubprocess' => $vendorDir . '/symfony/process/PhpSubprocess.php', + 'Symfony\\Component\\Process\\Pipes\\AbstractPipes' => $vendorDir . '/symfony/process/Pipes/AbstractPipes.php', + 'Symfony\\Component\\Process\\Pipes\\PipesInterface' => $vendorDir . '/symfony/process/Pipes/PipesInterface.php', + 'Symfony\\Component\\Process\\Pipes\\UnixPipes' => $vendorDir . '/symfony/process/Pipes/UnixPipes.php', + 'Symfony\\Component\\Process\\Pipes\\WindowsPipes' => $vendorDir . '/symfony/process/Pipes/WindowsPipes.php', + 'Symfony\\Component\\Process\\Process' => $vendorDir . '/symfony/process/Process.php', + 'Symfony\\Component\\Process\\ProcessUtils' => $vendorDir . '/symfony/process/ProcessUtils.php', 'Symfony\\Component\\Routing\\Alias' => $vendorDir . '/symfony/routing/Alias.php', 'Symfony\\Component\\Routing\\Annotation\\Route' => $vendorDir . '/symfony/routing/Annotation/Route.php', 'Symfony\\Component\\Routing\\Attribute\\Route' => $vendorDir . '/symfony/routing/Attribute/Route.php', diff --git a/lib/composer/autoload_psr4.php b/lib/composer/autoload_psr4.php index 7acbed6bf8..2c27d48b69 100644 --- a/lib/composer/autoload_psr4.php +++ b/lib/composer/autoload_psr4.php @@ -27,6 +27,7 @@ 'Symfony\\Component\\Stopwatch\\' => array($vendorDir . '/symfony/stopwatch'), 'Symfony\\Component\\Runtime\\' => array($vendorDir . '/symfony/runtime'), 'Symfony\\Component\\Routing\\' => array($vendorDir . '/symfony/routing'), + 'Symfony\\Component\\Process\\' => array($vendorDir . '/symfony/process'), 'Symfony\\Component\\Mime\\' => array($vendorDir . '/symfony/mime'), 'Symfony\\Component\\Mailer\\' => array($vendorDir . '/symfony/mailer'), 'Symfony\\Component\\HttpKernel\\' => array($vendorDir . '/symfony/http-kernel'), @@ -47,6 +48,9 @@ 'Symfony\\Bundle\\DebugBundle\\' => array($vendorDir . '/symfony/debug-bundle'), 'Symfony\\Bridge\\Twig\\' => array($vendorDir . '/symfony/twig-bridge'), 'Soundasleep\\' => array($vendorDir . '/soundasleep/html2text/src'), + 'SebastianFeldmann\\Git\\' => array($vendorDir . '/sebastianfeldmann/git/src'), + 'SebastianFeldmann\\Cli\\' => array($vendorDir . '/sebastianfeldmann/cli/src'), + 'SebastianFeldmann\\Camino\\' => array($vendorDir . '/sebastianfeldmann/camino/src'), 'ScssPhp\\ScssPhp\\' => array($vendorDir . '/scssphp/scssphp/src'), 'Sabberworm\\CSS\\' => array($vendorDir . '/sabberworm/php-css-parser/src'), 'Psr\\Log\\' => array($vendorDir . '/psr/log/src'), @@ -64,4 +68,6 @@ 'Firebase\\JWT\\' => array($vendorDir . '/firebase/php-jwt/src'), 'Egulias\\EmailValidator\\' => array($vendorDir . '/egulias/email-validator/src'), 'Doctrine\\Common\\Lexer\\' => array($vendorDir . '/doctrine/lexer/src'), + 'CaptainHook\\Secrets\\' => array($vendorDir . '/captainhook/secrets/src'), + 'CaptainHook\\App\\' => array($vendorDir . '/captainhook/captainhook/src'), ); diff --git a/lib/composer/autoload_static.php b/lib/composer/autoload_static.php index 4b1fc5b95a..75cb790dc4 100644 --- a/lib/composer/autoload_static.php +++ b/lib/composer/autoload_static.php @@ -54,6 +54,7 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'Symfony\\Component\\Stopwatch\\' => 28, 'Symfony\\Component\\Runtime\\' => 26, 'Symfony\\Component\\Routing\\' => 26, + 'Symfony\\Component\\Process\\' => 26, 'Symfony\\Component\\Mime\\' => 23, 'Symfony\\Component\\Mailer\\' => 25, 'Symfony\\Component\\HttpKernel\\' => 29, @@ -74,6 +75,9 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'Symfony\\Bundle\\DebugBundle\\' => 27, 'Symfony\\Bridge\\Twig\\' => 20, 'Soundasleep\\' => 12, + 'SebastianFeldmann\\Git\\' => 22, + 'SebastianFeldmann\\Cli\\' => 22, + 'SebastianFeldmann\\Camino\\' => 25, 'ScssPhp\\ScssPhp\\' => 16, 'Sabberworm\\CSS\\' => 15, ), @@ -110,6 +114,11 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f array ( 'Doctrine\\Common\\Lexer\\' => 22, ), + 'C' => + array ( + 'CaptainHook\\Secrets\\' => 20, + 'CaptainHook\\App\\' => 16, + ), ); public static $prefixDirsPsr4 = array ( @@ -197,6 +206,10 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f array ( 0 => __DIR__ . '/..' . '/symfony/routing', ), + 'Symfony\\Component\\Process\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/process', + ), 'Symfony\\Component\\Mime\\' => array ( 0 => __DIR__ . '/..' . '/symfony/mime', @@ -277,6 +290,18 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f array ( 0 => __DIR__ . '/..' . '/soundasleep/html2text/src', ), + 'SebastianFeldmann\\Git\\' => + array ( + 0 => __DIR__ . '/..' . '/sebastianfeldmann/git/src', + ), + 'SebastianFeldmann\\Cli\\' => + array ( + 0 => __DIR__ . '/..' . '/sebastianfeldmann/cli/src', + ), + 'SebastianFeldmann\\Camino\\' => + array ( + 0 => __DIR__ . '/..' . '/sebastianfeldmann/camino/src', + ), 'ScssPhp\\ScssPhp\\' => array ( 0 => __DIR__ . '/..' . '/scssphp/scssphp/src', @@ -347,6 +372,14 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f array ( 0 => __DIR__ . '/..' . '/doctrine/lexer/src', ), + 'CaptainHook\\Secrets\\' => + array ( + 0 => __DIR__ . '/..' . '/captainhook/secrets/src', + ), + 'CaptainHook\\App\\' => + array ( + 0 => __DIR__ . '/..' . '/captainhook/captainhook/src', + ), ); public static $prefixesPsr0 = array ( @@ -543,6 +576,235 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'CSVBulkExport' => __DIR__ . '/../..' . '/core/csvbulkexport.class.inc.php', 'CSVParser' => __DIR__ . '/../..' . '/core/csvparser.class.inc.php', 'CSVParserException' => __DIR__ . '/../..' . '/application/exceptions/CSVParserException.php', + 'CaptainHook\\App\\CH' => __DIR__ . '/..' . '/captainhook/captainhook/src/CH.php', + 'CaptainHook\\App\\Config' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config.php', + 'CaptainHook\\App\\Config\\Action' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Action.php', + 'CaptainHook\\App\\Config\\Condition' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Condition.php', + 'CaptainHook\\App\\Config\\Factory' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Factory.php', + 'CaptainHook\\App\\Config\\Hook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Hook.php', + 'CaptainHook\\App\\Config\\Options' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Options.php', + 'CaptainHook\\App\\Config\\Plugin' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Plugin.php', + 'CaptainHook\\App\\Config\\Run' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Run.php', + 'CaptainHook\\App\\Config\\Util' => __DIR__ . '/..' . '/captainhook/captainhook/src/Config/Util.php', + 'CaptainHook\\App\\Console\\Application' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Application.php', + 'CaptainHook\\App\\Console\\Command' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command.php', + 'CaptainHook\\App\\Console\\Command\\Add' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Add.php', + 'CaptainHook\\App\\Console\\Command\\ConfigAware' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/ConfigAware.php', + 'CaptainHook\\App\\Console\\Command\\Configuration' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Configuration.php', + 'CaptainHook\\App\\Console\\Command\\Disable' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Disable.php', + 'CaptainHook\\App\\Console\\Command\\Enable' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Enable.php', + 'CaptainHook\\App\\Console\\Command\\Hook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\CommitMsg' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/CommitMsg.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostCheckout' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/PostCheckout.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostCommit' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/PostCommit.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostMerge' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/PostMerge.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PostRewrite' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/PostRewrite.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PreCommit' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/PreCommit.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PrePush' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/PrePush.php', + 'CaptainHook\\App\\Console\\Command\\Hook\\PrepareCommitMsg' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Hook/PrepareCommitMsg.php', + 'CaptainHook\\App\\Console\\Command\\Info' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Info.php', + 'CaptainHook\\App\\Console\\Command\\Install' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Install.php', + 'CaptainHook\\App\\Console\\Command\\RepositoryAware' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/RepositoryAware.php', + 'CaptainHook\\App\\Console\\Command\\Uninstall' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Command/Uninstall.php', + 'CaptainHook\\App\\Console\\IO' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IO.php', + 'CaptainHook\\App\\Console\\IOUtil' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IOUtil.php', + 'CaptainHook\\App\\Console\\IO\\Base' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IO/Base.php', + 'CaptainHook\\App\\Console\\IO\\CollectorIO' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IO/CollectorIO.php', + 'CaptainHook\\App\\Console\\IO\\ComposerIO' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IO/ComposerIO.php', + 'CaptainHook\\App\\Console\\IO\\DefaultIO' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IO/DefaultIO.php', + 'CaptainHook\\App\\Console\\IO\\Message' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IO/Message.php', + 'CaptainHook\\App\\Console\\IO\\NullIO' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/IO/NullIO.php', + 'CaptainHook\\App\\Console\\Runtime\\Resolver' => __DIR__ . '/..' . '/captainhook/captainhook/src/Console/Runtime/Resolver.php', + 'CaptainHook\\App\\Event' => __DIR__ . '/..' . '/captainhook/captainhook/src/Event.php', + 'CaptainHook\\App\\Event\\Dispatcher' => __DIR__ . '/..' . '/captainhook/captainhook/src/Event/Dispatcher.php', + 'CaptainHook\\App\\Event\\Factory' => __DIR__ . '/..' . '/captainhook/captainhook/src/Event/Factory.php', + 'CaptainHook\\App\\Event\\Handler' => __DIR__ . '/..' . '/captainhook/captainhook/src/Event/Handler.php', + 'CaptainHook\\App\\Event\\Hook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Event/Hook.php', + 'CaptainHook\\App\\Event\\HookFailed' => __DIR__ . '/..' . '/captainhook/captainhook/src/Event/HookFailed.php', + 'CaptainHook\\App\\Event\\HookSucceeded' => __DIR__ . '/..' . '/captainhook/captainhook/src/Event/HookSucceeded.php', + 'CaptainHook\\App\\Exception\\ActionFailed' => __DIR__ . '/..' . '/captainhook/captainhook/src/Exception/ActionFailed.php', + 'CaptainHook\\App\\Exception\\CaptainHookException' => __DIR__ . '/..' . '/captainhook/captainhook/src/Exception/CaptainHookException.php', + 'CaptainHook\\App\\Exception\\InvalidHookName' => __DIR__ . '/..' . '/captainhook/captainhook/src/Exception/InvalidHookName.php', + 'CaptainHook\\App\\Exception\\InvalidPlugin' => __DIR__ . '/..' . '/captainhook/captainhook/src/Exception/InvalidPlugin.php', + 'CaptainHook\\App\\Git\\ChangedFiles' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/ChangedFiles.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detecting' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/ChangedFiles/Detecting.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/ChangedFiles/Detector.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\Factory' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/Factory.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\Fallback' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/Fallback.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\PostRewrite' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/PostRewrite.php', + 'CaptainHook\\App\\Git\\ChangedFiles\\Detector\\PrePush' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/ChangedFiles/Detector/PrePush.php', + 'CaptainHook\\App\\Git\\Diff\\FilterUtil' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Diff/FilterUtil.php', + 'CaptainHook\\App\\Git\\Range' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Range.php', + 'CaptainHook\\App\\Git\\Range\\Detecting' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Range/Detecting.php', + 'CaptainHook\\App\\Git\\Range\\Detector\\Fallback' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Range/Detector/Fallback.php', + 'CaptainHook\\App\\Git\\Range\\Detector\\PostRewrite' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Range/Detector/PostRewrite.php', + 'CaptainHook\\App\\Git\\Range\\Detector\\PrePush' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Range/Detector/PrePush.php', + 'CaptainHook\\App\\Git\\Range\\Generic' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Range/Generic.php', + 'CaptainHook\\App\\Git\\Range\\PrePush' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Range/PrePush.php', + 'CaptainHook\\App\\Git\\Rev' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Rev.php', + 'CaptainHook\\App\\Git\\Rev\\Generic' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Rev/Generic.php', + 'CaptainHook\\App\\Git\\Rev\\PrePush' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Rev/PrePush.php', + 'CaptainHook\\App\\Git\\Rev\\Util' => __DIR__ . '/..' . '/captainhook/captainhook/src/Git/Rev/Util.php', + 'CaptainHook\\App\\Hook\\Action' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Action.php', + 'CaptainHook\\App\\Hook\\Branch\\Action\\BlockFixupAndSquashCommits' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Branch/Action/BlockFixupAndSquashCommits.php', + 'CaptainHook\\App\\Hook\\Branch\\Action\\EnsureNaming' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Branch/Action/EnsureNaming.php', + 'CaptainHook\\App\\Hook\\Cli\\Command' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Cli/Command.php', + 'CaptainHook\\App\\Hook\\Composer\\Action\\CheckLockFile' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Composer/Action/CheckLockFile.php', + 'CaptainHook\\App\\Hook\\Condition' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\Files' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Branch/Files.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\Name' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Branch/Name.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\NotOn' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Branch/NotOn.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\NotOnMatching' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Branch/NotOnMatching.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\On' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Branch/On.php', + 'CaptainHook\\App\\Hook\\Condition\\Branch\\OnMatching' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Branch/OnMatching.php', + 'CaptainHook\\App\\Hook\\Condition\\Cli' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Cli.php', + 'CaptainHook\\App\\Hook\\Condition\\Config' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Config.php', + 'CaptainHook\\App\\Hook\\Condition\\ConfigDependant' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/ConfigDependant.php', + 'CaptainHook\\App\\Hook\\Condition\\Config\\CustomValueIsFalsy' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsFalsy.php', + 'CaptainHook\\App\\Hook\\Condition\\Config\\CustomValueIsTruthy' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Config/CustomValueIsTruthy.php', + 'CaptainHook\\App\\Hook\\Condition\\File' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/File.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileChanged.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged\\All' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileChanged/All.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged\\Any' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileChanged/Any.php', + 'CaptainHook\\App\\Hook\\Condition\\FileChanged\\OfType' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileChanged/OfType.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileStaged.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\All' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileStaged/All.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\Any' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileStaged/Any.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\InDirectory' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileStaged/InDirectory.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\OfType' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileStaged/OfType.php', + 'CaptainHook\\App\\Hook\\Condition\\FileStaged\\ThatIs' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/FileStaged/ThatIs.php', + 'CaptainHook\\App\\Hook\\Condition\\Logic' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Logic.php', + 'CaptainHook\\App\\Hook\\Condition\\Logic\\LogicAnd' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Logic/LogicAnd.php', + 'CaptainHook\\App\\Hook\\Condition\\Logic\\LogicOr' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/Logic/LogicOr.php', + 'CaptainHook\\App\\Hook\\Condition\\OnBranch' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Condition/OnBranch.php', + 'CaptainHook\\App\\Hook\\Constrained' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Constrained.php', + 'CaptainHook\\App\\Hook\\Debug' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Debug.php', + 'CaptainHook\\App\\Hook\\Debug\\Failure' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Debug/Failure.php', + 'CaptainHook\\App\\Hook\\Debug\\Success' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Debug/Success.php', + 'CaptainHook\\App\\Hook\\Diff\\Action\\BlockSecrets' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Diff/Action/BlockSecrets.php', + 'CaptainHook\\App\\Hook\\EventSubscriber' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/EventSubscriber.php', + 'CaptainHook\\App\\Hook\\FileList' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/FileList.php', + 'CaptainHook\\App\\Hook\\File\\Action\\Check' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/File/Action/Check.php', + 'CaptainHook\\App\\Hook\\File\\Action\\DoesNotContainRegex' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/File/Action/DoesNotContainRegex.php', + 'CaptainHook\\App\\Hook\\File\\Action\\Emptiness' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/File/Action/Emptiness.php', + 'CaptainHook\\App\\Hook\\File\\Action\\Exists' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/File/Action/Exists.php', + 'CaptainHook\\App\\Hook\\File\\Action\\IsEmpty' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/File/Action/IsEmpty.php', + 'CaptainHook\\App\\Hook\\File\\Action\\IsNotEmpty' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/File/Action/IsNotEmpty.php', + 'CaptainHook\\App\\Hook\\File\\Action\\MaxSize' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/File/Action/MaxSize.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Beams' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/Beams.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Book' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/Book.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\CacheOnFail' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/CacheOnFail.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\InjectIssueKeyFromBranch' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/InjectIssueKeyFromBranch.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Prepare' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/Prepare.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\PrepareFromFile' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/PrepareFromFile.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Regex' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/Regex.php', + 'CaptainHook\\App\\Hook\\Message\\Action\\Rules' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Action/Rules.php', + 'CaptainHook\\App\\Hook\\Message\\EventHandler\\WriteCacheFile' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/EventHandler/WriteCacheFile.php', + 'CaptainHook\\App\\Hook\\Message\\Rule' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule.php', + 'CaptainHook\\App\\Hook\\Message\\RuleBook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/RuleBook.php', + 'CaptainHook\\App\\Hook\\Message\\RuleBook\\RuleSet' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/RuleBook/RuleSet.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\Base' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/Base.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\Blacklist' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/Blacklist.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\CapitalizeSubject' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/CapitalizeSubject.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\LimitBodyLineLength' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/LimitBodyLineLength.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\LimitSubjectLength' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/LimitSubjectLength.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\MsgNotEmpty' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/MsgNotEmpty.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\NoPeriodOnSubjectEnd' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/NoPeriodOnSubjectEnd.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\SeparateSubjectFromBodyWithBlankLine' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/SeparateSubjectFromBodyWithBlankLine.php', + 'CaptainHook\\App\\Hook\\Message\\Rule\\UseImperativeMood' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Message/Rule/UseImperativeMood.php', + 'CaptainHook\\App\\Hook\\Notify\\Action\\IntegrateBeforePush' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Notify/Action/IntegrateBeforePush.php', + 'CaptainHook\\App\\Hook\\Notify\\Action\\Notify' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Notify/Action/Notify.php', + 'CaptainHook\\App\\Hook\\Notify\\Extractor' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Notify/Extractor.php', + 'CaptainHook\\App\\Hook\\Notify\\Notification' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Notify/Notification.php', + 'CaptainHook\\App\\Hook\\PHP\\Action\\Linting' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/PHP/Action/Linting.php', + 'CaptainHook\\App\\Hook\\PHP\\Action\\TestCoverage' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/PHP/Action/TestCoverage.php', + 'CaptainHook\\App\\Hook\\PHP\\CoverageResolver' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/PHP/CoverageResolver.php', + 'CaptainHook\\App\\Hook\\PHP\\CoverageResolver\\CloverXML' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/PHP/CoverageResolver/CloverXML.php', + 'CaptainHook\\App\\Hook\\PHP\\CoverageResolver\\PHPUnit' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/PHP/CoverageResolver/PHPUnit.php', + 'CaptainHook\\App\\Hook\\Restriction' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Restriction.php', + 'CaptainHook\\App\\Hook\\Template' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template.php', + 'CaptainHook\\App\\Hook\\Template\\Builder' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/Builder.php', + 'CaptainHook\\App\\Hook\\Template\\Docker' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/Docker.php', + 'CaptainHook\\App\\Hook\\Template\\Inspector' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/Inspector.php', + 'CaptainHook\\App\\Hook\\Template\\Local' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/Local.php', + 'CaptainHook\\App\\Hook\\Template\\Local\\PHP' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/Local/PHP.php', + 'CaptainHook\\App\\Hook\\Template\\Local\\Shell' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/Local/Shell.php', + 'CaptainHook\\App\\Hook\\Template\\Local\\WSL' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/Local/WSL.php', + 'CaptainHook\\App\\Hook\\Template\\PathInfo' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Template/PathInfo.php', + 'CaptainHook\\App\\Hook\\UserInput\\AskConfirmation' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/UserInput/AskConfirmation.php', + 'CaptainHook\\App\\Hook\\UserInput\\EventHandler\\AskConfirmation' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/UserInput/EventHandler/AskConfirmation.php', + 'CaptainHook\\App\\Hook\\Util' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hook/Util.php', + 'CaptainHook\\App\\Hooks' => __DIR__ . '/..' . '/captainhook/captainhook/src/Hooks.php', + 'CaptainHook\\App\\Plugin\\CaptainHook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Plugin/CaptainHook.php', + 'CaptainHook\\App\\Plugin\\Hook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Plugin/Hook.php', + 'CaptainHook\\App\\Plugin\\Hook\\Base' => __DIR__ . '/..' . '/captainhook/captainhook/src/Plugin/Hook/Base.php', + 'CaptainHook\\App\\Plugin\\Hook\\PreserveWorkingTree' => __DIR__ . '/..' . '/captainhook/captainhook/src/Plugin/Hook/PreserveWorkingTree.php', + 'CaptainHook\\App\\Runner' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner.php', + 'CaptainHook\\App\\Runner\\Action' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action.php', + 'CaptainHook\\App\\Runner\\Action\\Cli' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Formatter' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Formatter.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Arg' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Arg.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\BranchFiles' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/BranchFiles.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\ChangedFiles' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/ChangedFiles.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Config' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Config.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Env' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Env.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\Foundation' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/Foundation.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\StagedFiles' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StagedFiles.php', + 'CaptainHook\\App\\Runner\\Action\\Cli\\Command\\Placeholder\\StdIn' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Cli/Command/Placeholder/StdIn.php', + 'CaptainHook\\App\\Runner\\Action\\Log' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/Log.php', + 'CaptainHook\\App\\Runner\\Action\\PHP' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Action/PHP.php', + 'CaptainHook\\App\\Runner\\Bootstrap\\Util' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Bootstrap/Util.php', + 'CaptainHook\\App\\Runner\\Condition' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Condition.php', + 'CaptainHook\\App\\Runner\\Config\\Change' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Change.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\AddAction' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Change/AddAction.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\DisableHook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Change/DisableHook.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\EnableHook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Change/EnableHook.php', + 'CaptainHook\\App\\Runner\\Config\\Change\\Hook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Change/Hook.php', + 'CaptainHook\\App\\Runner\\Config\\Creator' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Creator.php', + 'CaptainHook\\App\\Runner\\Config\\Editor' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Editor.php', + 'CaptainHook\\App\\Runner\\Config\\Reader' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Reader.php', + 'CaptainHook\\App\\Runner\\Config\\Setup' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Setup.php', + 'CaptainHook\\App\\Runner\\Config\\Setup\\Advanced' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Setup/Advanced.php', + 'CaptainHook\\App\\Runner\\Config\\Setup\\Express' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Setup/Express.php', + 'CaptainHook\\App\\Runner\\Config\\Setup\\Guided' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Config/Setup/Guided.php', + 'CaptainHook\\App\\Runner\\Files' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Files.php', + 'CaptainHook\\App\\Runner\\Hook' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook.php', + 'CaptainHook\\App\\Runner\\Hook\\Arg' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/Arg.php', + 'CaptainHook\\App\\Runner\\Hook\\CommitMsg' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/CommitMsg.php', + 'CaptainHook\\App\\Runner\\Hook\\Log' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/Log.php', + 'CaptainHook\\App\\Runner\\Hook\\PostCheckout' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/PostCheckout.php', + 'CaptainHook\\App\\Runner\\Hook\\PostCommit' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/PostCommit.php', + 'CaptainHook\\App\\Runner\\Hook\\PostMerge' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/PostMerge.php', + 'CaptainHook\\App\\Runner\\Hook\\PostRewrite' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/PostRewrite.php', + 'CaptainHook\\App\\Runner\\Hook\\PreCommit' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/PreCommit.php', + 'CaptainHook\\App\\Runner\\Hook\\PrePush' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/PrePush.php', + 'CaptainHook\\App\\Runner\\Hook\\PrepareCommitMsg' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/PrepareCommitMsg.php', + 'CaptainHook\\App\\Runner\\Hook\\Printer' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Hook/Printer.php', + 'CaptainHook\\App\\Runner\\Installer' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Installer.php', + 'CaptainHook\\App\\Runner\\RepositoryAware' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/RepositoryAware.php', + 'CaptainHook\\App\\Runner\\Uninstaller' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Uninstaller.php', + 'CaptainHook\\App\\Runner\\Util' => __DIR__ . '/..' . '/captainhook/captainhook/src/Runner/Util.php', + 'CaptainHook\\App\\Storage\\File' => __DIR__ . '/..' . '/captainhook/captainhook/src/Storage/File.php', + 'CaptainHook\\App\\Storage\\File\\Json' => __DIR__ . '/..' . '/captainhook/captainhook/src/Storage/File/Json.php', + 'CaptainHook\\App\\Storage\\File\\Xml' => __DIR__ . '/..' . '/captainhook/captainhook/src/Storage/File/Xml.php', + 'CaptainHook\\Secrets\\Detector' => __DIR__ . '/..' . '/captainhook/secrets/src/Detector.php', + 'CaptainHook\\Secrets\\Entropy\\Shannon' => __DIR__ . '/..' . '/captainhook/secrets/src/Entropy/Shannon.php', + 'CaptainHook\\Secrets\\Regex\\Grouped' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Grouped.php', + 'CaptainHook\\Secrets\\Regex\\Supplier' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Aws' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Aws.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\GitHub' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/GitHub.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Gitlab' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Gitlab.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Google' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Google.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Ini' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Ini.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Json' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Json.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\PHP' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/PHP.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Password' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Password.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Stripe' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Stripe.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Util' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Util.php', + 'CaptainHook\\Secrets\\Regex\\Supplier\\Yaml' => __DIR__ . '/..' . '/captainhook/secrets/src/Regex/Supplier/Yaml.php', + 'CaptainHook\\Secrets\\Regexer' => __DIR__ . '/..' . '/captainhook/secrets/src/Regexer.php', + 'CaptainHook\\Secrets\\Result' => __DIR__ . '/..' . '/captainhook/secrets/src/Result.php', 'CellChangeSpec' => __DIR__ . '/../..' . '/core/bulkchange.class.inc.php', 'CellStatus_Ambiguous' => __DIR__ . '/../..' . '/core/bulkchange.class.inc.php', 'CellStatus_Issue' => __DIR__ . '/../..' . '/core/bulkchange.class.inc.php', @@ -1763,6 +2025,83 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'ScssPhp\\ScssPhp\\Version' => __DIR__ . '/..' . '/scssphp/scssphp/src/Version.php', 'ScssPhp\\ScssPhp\\Warn' => __DIR__ . '/..' . '/scssphp/scssphp/src/Warn.php', 'SearchMenuNode' => __DIR__ . '/../..' . '/application/menunode.class.inc.php', + 'SebastianFeldmann\\Camino\\Check' => __DIR__ . '/..' . '/sebastianfeldmann/camino/src/Check.php', + 'SebastianFeldmann\\Camino\\Path' => __DIR__ . '/..' . '/sebastianfeldmann/camino/src/Path.php', + 'SebastianFeldmann\\Camino\\Path\\Base' => __DIR__ . '/..' . '/sebastianfeldmann/camino/src/Path/Base.php', + 'SebastianFeldmann\\Camino\\Path\\Directory' => __DIR__ . '/..' . '/sebastianfeldmann/camino/src/Path/Directory.php', + 'SebastianFeldmann\\Camino\\Path\\File' => __DIR__ . '/..' . '/sebastianfeldmann/camino/src/Path/File.php', + 'SebastianFeldmann\\Cli\\Command' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Command.php', + 'SebastianFeldmann\\Cli\\CommandLine' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/CommandLine.php', + 'SebastianFeldmann\\Cli\\Command\\Executable' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Command/Executable.php', + 'SebastianFeldmann\\Cli\\Command\\OutputFormatter' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Command/OutputFormatter.php', + 'SebastianFeldmann\\Cli\\Command\\Result' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Command/Result.php', + 'SebastianFeldmann\\Cli\\Command\\Runner' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Command/Runner.php', + 'SebastianFeldmann\\Cli\\Command\\Runner\\Result' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Command/Runner/Result.php', + 'SebastianFeldmann\\Cli\\Command\\Runner\\Simple' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Command/Runner/Simple.php', + 'SebastianFeldmann\\Cli\\Output\\Util' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Output/Util.php', + 'SebastianFeldmann\\Cli\\Processor' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Processor.php', + 'SebastianFeldmann\\Cli\\Processor\\ProcOpen' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Processor/ProcOpen.php', + 'SebastianFeldmann\\Cli\\Processor\\Symfony' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Processor/Symfony.php', + 'SebastianFeldmann\\Cli\\Reader' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Reader.php', + 'SebastianFeldmann\\Cli\\Reader\\Abstraction' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Reader/Abstraction.php', + 'SebastianFeldmann\\Cli\\Reader\\StandardInput' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Reader/StandardInput.php', + 'SebastianFeldmann\\Cli\\Util' => __DIR__ . '/..' . '/sebastianfeldmann/cli/src/Util.php', + 'SebastianFeldmann\\Git\\Command\\Add\\AddFiles' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Add/AddFiles.php', + 'SebastianFeldmann\\Git\\Command\\Apply\\ApplyPatch' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Apply/ApplyPatch.php', + 'SebastianFeldmann\\Git\\Command\\Base' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Base.php', + 'SebastianFeldmann\\Git\\Command\\Branch\\ListRemote' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Branch/ListRemote.php', + 'SebastianFeldmann\\Git\\Command\\Checkout\\RestoreWorkingTree' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Checkout/RestoreWorkingTree.php', + 'SebastianFeldmann\\Git\\Command\\CloneCmd\\CloneCmd' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/CloneCmd/CloneCmd.php', + 'SebastianFeldmann\\Git\\Command\\Config\\Get' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Config/Get.php', + 'SebastianFeldmann\\Git\\Command\\Config\\GetVar' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Config/GetVar.php', + 'SebastianFeldmann\\Git\\Command\\Config\\ListSettings' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Config/ListSettings.php', + 'SebastianFeldmann\\Git\\Command\\Config\\ListVars' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Config/ListVars.php', + 'SebastianFeldmann\\Git\\Command\\Config\\MapValues' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Config/MapValues.php', + 'SebastianFeldmann\\Git\\Command\\Describe\\GetCurrentTag' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Describe/GetCurrentTag.php', + 'SebastianFeldmann\\Git\\Command\\Describe\\GetMostRecentTag' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Describe/GetMostRecentTag.php', + 'SebastianFeldmann\\Git\\Command\\DiffIndex\\GetStagedFiles' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles.php', + 'SebastianFeldmann\\Git\\Command\\DiffIndex\\GetStagedFiles\\FilterByStatus' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles/FilterByStatus.php', + 'SebastianFeldmann\\Git\\Command\\DiffIndex\\GetUnstagedPatch' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/DiffIndex/GetUnstagedPatch.php', + 'SebastianFeldmann\\Git\\Command\\DiffTree\\ChangedFiles' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/DiffTree/ChangedFiles.php', + 'SebastianFeldmann\\Git\\Command\\Diff\\ChangedFiles' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Diff/ChangedFiles.php', + 'SebastianFeldmann\\Git\\Command\\Diff\\Compare' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Diff/Compare.php', + 'SebastianFeldmann\\Git\\Command\\Diff\\Compare\\FullDiffList' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Diff/Compare/FullDiffList.php', + 'SebastianFeldmann\\Git\\Command\\Fetch\\Fetch' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Fetch/Fetch.php', + 'SebastianFeldmann\\Git\\Command\\Log\\ChangedFiles' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Log/ChangedFiles.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Commits' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Log/Commits.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Commits\\Jsonized' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Log/Commits/Jsonized.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Commits\\Xml' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Log/Commits/Xml.php', + 'SebastianFeldmann\\Git\\Command\\Log\\Log' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Log/Log.php', + 'SebastianFeldmann\\Git\\Command\\LsTree\\GetFiles' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/LsTree/GetFiles.php', + 'SebastianFeldmann\\Git\\Command\\MergeBase\\MergeBase' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/MergeBase/MergeBase.php', + 'SebastianFeldmann\\Git\\Command\\Output\\Exploded' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Output/Exploded.php', + 'SebastianFeldmann\\Git\\Command\\Pull\\Pull' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Pull/Pull.php', + 'SebastianFeldmann\\Git\\Command\\RefLog\\BranchRevs' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/RefLog/BranchRevs.php', + 'SebastianFeldmann\\Git\\Command\\RevParse\\GetBranch' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/RevParse/GetBranch.php', + 'SebastianFeldmann\\Git\\Command\\RevParse\\GetCommitHash' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/RevParse/GetCommitHash.php', + 'SebastianFeldmann\\Git\\Command\\Rm\\RemoveFiles' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Rm/RemoveFiles.php', + 'SebastianFeldmann\\Git\\Command\\Status\\Porcelain\\PathList' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Status/Porcelain/PathList.php', + 'SebastianFeldmann\\Git\\Command\\Status\\WorkingTreeStatus' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Status/WorkingTreeStatus.php', + 'SebastianFeldmann\\Git\\Command\\Tag\\GetTags' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/Tag/GetTags.php', + 'SebastianFeldmann\\Git\\Command\\WriteTree\\CreateTreeObject' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Command/WriteTree/CreateTreeObject.php', + 'SebastianFeldmann\\Git\\CommitMessage' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/CommitMessage.php', + 'SebastianFeldmann\\Git\\Diff\\Change' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Diff/Change.php', + 'SebastianFeldmann\\Git\\Diff\\File' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Diff/File.php', + 'SebastianFeldmann\\Git\\Diff\\FilterUtil' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Diff/FilterUtil.php', + 'SebastianFeldmann\\Git\\Diff\\Line' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Diff/Line.php', + 'SebastianFeldmann\\Git\\Log\\Commit' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Log/Commit.php', + 'SebastianFeldmann\\Git\\Operator\\Base' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Base.php', + 'SebastianFeldmann\\Git\\Operator\\Config' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Config.php', + 'SebastianFeldmann\\Git\\Operator\\Diff' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Diff.php', + 'SebastianFeldmann\\Git\\Operator\\Index' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Index.php', + 'SebastianFeldmann\\Git\\Operator\\Info' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Info.php', + 'SebastianFeldmann\\Git\\Operator\\Log' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Log.php', + 'SebastianFeldmann\\Git\\Operator\\Remote' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Remote.php', + 'SebastianFeldmann\\Git\\Operator\\Status' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Operator/Status.php', + 'SebastianFeldmann\\Git\\Repository' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Repository.php', + 'SebastianFeldmann\\Git\\Repository\\Cloner' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Repository/Cloner.php', + 'SebastianFeldmann\\Git\\Status\\Path' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Status/Path.php', + 'SebastianFeldmann\\Git\\Url' => __DIR__ . '/..' . '/sebastianfeldmann/git/src/Url.php', 'SecurityException' => __DIR__ . '/../..' . '/application/exceptions/SecurityException.php', 'SeparatorPopupMenuItem' => __DIR__ . '/../..' . '/application/applicationextension.inc.php', 'SetupLog' => __DIR__ . '/../..' . '/core/log.class.inc.php', @@ -2929,6 +3268,28 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'Symfony\\Component\\Mime\\Part\\SMimePart' => __DIR__ . '/..' . '/symfony/mime/Part/SMimePart.php', 'Symfony\\Component\\Mime\\Part\\TextPart' => __DIR__ . '/..' . '/symfony/mime/Part/TextPart.php', 'Symfony\\Component\\Mime\\RawMessage' => __DIR__ . '/..' . '/symfony/mime/RawMessage.php', + 'Symfony\\Component\\Process\\Exception\\ExceptionInterface' => __DIR__ . '/..' . '/symfony/process/Exception/ExceptionInterface.php', + 'Symfony\\Component\\Process\\Exception\\InvalidArgumentException' => __DIR__ . '/..' . '/symfony/process/Exception/InvalidArgumentException.php', + 'Symfony\\Component\\Process\\Exception\\LogicException' => __DIR__ . '/..' . '/symfony/process/Exception/LogicException.php', + 'Symfony\\Component\\Process\\Exception\\ProcessFailedException' => __DIR__ . '/..' . '/symfony/process/Exception/ProcessFailedException.php', + 'Symfony\\Component\\Process\\Exception\\ProcessSignaledException' => __DIR__ . '/..' . '/symfony/process/Exception/ProcessSignaledException.php', + 'Symfony\\Component\\Process\\Exception\\ProcessTimedOutException' => __DIR__ . '/..' . '/symfony/process/Exception/ProcessTimedOutException.php', + 'Symfony\\Component\\Process\\Exception\\RunProcessFailedException' => __DIR__ . '/..' . '/symfony/process/Exception/RunProcessFailedException.php', + 'Symfony\\Component\\Process\\Exception\\RuntimeException' => __DIR__ . '/..' . '/symfony/process/Exception/RuntimeException.php', + 'Symfony\\Component\\Process\\ExecutableFinder' => __DIR__ . '/..' . '/symfony/process/ExecutableFinder.php', + 'Symfony\\Component\\Process\\InputStream' => __DIR__ . '/..' . '/symfony/process/InputStream.php', + 'Symfony\\Component\\Process\\Messenger\\RunProcessContext' => __DIR__ . '/..' . '/symfony/process/Messenger/RunProcessContext.php', + 'Symfony\\Component\\Process\\Messenger\\RunProcessMessage' => __DIR__ . '/..' . '/symfony/process/Messenger/RunProcessMessage.php', + 'Symfony\\Component\\Process\\Messenger\\RunProcessMessageHandler' => __DIR__ . '/..' . '/symfony/process/Messenger/RunProcessMessageHandler.php', + 'Symfony\\Component\\Process\\PhpExecutableFinder' => __DIR__ . '/..' . '/symfony/process/PhpExecutableFinder.php', + 'Symfony\\Component\\Process\\PhpProcess' => __DIR__ . '/..' . '/symfony/process/PhpProcess.php', + 'Symfony\\Component\\Process\\PhpSubprocess' => __DIR__ . '/..' . '/symfony/process/PhpSubprocess.php', + 'Symfony\\Component\\Process\\Pipes\\AbstractPipes' => __DIR__ . '/..' . '/symfony/process/Pipes/AbstractPipes.php', + 'Symfony\\Component\\Process\\Pipes\\PipesInterface' => __DIR__ . '/..' . '/symfony/process/Pipes/PipesInterface.php', + 'Symfony\\Component\\Process\\Pipes\\UnixPipes' => __DIR__ . '/..' . '/symfony/process/Pipes/UnixPipes.php', + 'Symfony\\Component\\Process\\Pipes\\WindowsPipes' => __DIR__ . '/..' . '/symfony/process/Pipes/WindowsPipes.php', + 'Symfony\\Component\\Process\\Process' => __DIR__ . '/..' . '/symfony/process/Process.php', + 'Symfony\\Component\\Process\\ProcessUtils' => __DIR__ . '/..' . '/symfony/process/ProcessUtils.php', 'Symfony\\Component\\Routing\\Alias' => __DIR__ . '/..' . '/symfony/routing/Alias.php', 'Symfony\\Component\\Routing\\Annotation\\Route' => __DIR__ . '/..' . '/symfony/routing/Annotation/Route.php', 'Symfony\\Component\\Routing\\Attribute\\Route' => __DIR__ . '/..' . '/symfony/routing/Attribute/Route.php', diff --git a/lib/composer/installed.json b/lib/composer/installed.json index 7b246762bb..293b82abc4 100644 --- a/lib/composer/installed.json +++ b/lib/composer/installed.json @@ -74,6 +74,152 @@ }, "install-path": "../apereo/phpcas" }, + { + "name": "captainhook/captainhook", + "version": "5.25.11", + "version_normalized": "5.25.11.0", + "source": { + "type": "git", + "url": "https://github.com/captainhook-git/captainhook.git", + "reference": "f2278edde4b45af353861aae413fc3840515bb80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhook-git/captainhook/zipball/f2278edde4b45af353861aae413fc3840515bb80", + "reference": "f2278edde4b45af353861aae413fc3840515bb80", + "shasum": "" + }, + "require": { + "captainhook/secrets": "^0.9.4", + "ext-json": "*", + "ext-spl": "*", + "ext-xml": "*", + "php": ">=8.0", + "sebastianfeldmann/camino": "^0.9.2", + "sebastianfeldmann/cli": "^3.3", + "sebastianfeldmann/git": "^3.14", + "symfony/console": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/filesystem": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/process": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "sebastianfeldmann/captainhook": "*" + }, + "require-dev": { + "composer/composer": "~1 || ^2.0", + "mikey179/vfsstream": "~1" + }, + "time": "2025-08-12T12:14:57+00:00", + "bin": [ + "bin/captainhook" + ], + "type": "library", + "extra": { + "captainhook": { + "config": "captainhook.json" + }, + "branch-alias": { + "dev-main": "6.0.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "CaptainHook\\App\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git hook manager", + "homepage": "http://php.captainhook.info/", + "keywords": [ + "commit-msg", + "git", + "hooks", + "post-merge", + "pre-commit", + "pre-push", + "prepare-commit-msg" + ], + "support": { + "issues": "https://github.com/captainhook-git/captainhook/issues", + "source": "https://github.com/captainhook-git/captainhook/tree/5.25.11" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "install-path": "../captainhook/captainhook" + }, + { + "name": "captainhook/secrets", + "version": "0.9.7", + "version_normalized": "0.9.7.0", + "source": { + "type": "git", + "url": "https://github.com/captainhook-git/secrets.git", + "reference": "d62c97f75f81ac98e22f1c282482bd35fa82f631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhook-git/secrets/zipball/d62c97f75f81ac98e22f1c282482bd35fa82f631", + "reference": "d62c97f75f81ac98e22f1c282482bd35fa82f631", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.0" + }, + "time": "2025-04-08T07:10:48+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "CaptainHook\\Secrets\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Utility classes to detect secrets", + "keywords": [ + "commit-msg", + "keys", + "passwords", + "post-merge", + "prepare-commit-msg", + "secrets", + "tokens" + ], + "support": { + "issues": "https://github.com/captainhook-git/secrets/issues", + "source": "https://github.com/captainhook-git/secrets/tree/0.9.7" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "install-path": "../captainhook/secrets" + }, { "name": "doctrine/lexer", "version": "3.0.1", @@ -1762,6 +1908,191 @@ }, "install-path": "../scssphp/scssphp" }, + { + "name": "sebastianfeldmann/camino", + "version": "0.9.5", + "version_normalized": "0.9.5.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/camino.git", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/camino/zipball/bf2e4c8b2a029e9eade43666132b61331e3e8184", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "time": "2022-01-03T13:15:10+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "SebastianFeldmann\\Camino\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Path management the OO way", + "homepage": "https://github.com/sebastianfeldmann/camino", + "keywords": [ + "file system", + "path" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/camino/issues", + "source": "https://github.com/sebastianfeldmann/camino/tree/0.9.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "install-path": "../sebastianfeldmann/camino" + }, + { + "name": "sebastianfeldmann/cli", + "version": "3.4.2", + "version_normalized": "3.4.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/cli.git", + "reference": "6fa122afd528dae7d7ec988a604aa6c600f5d9b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/cli/zipball/6fa122afd528dae7d7ec988a604aa6c600f5d9b5", + "reference": "6fa122afd528dae7d7ec988a604aa6c600f5d9b5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "symfony/process": "^4.3 | ^5.0" + }, + "time": "2024-11-26T10:19:01+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "SebastianFeldmann\\Cli\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP cli helper classes", + "homepage": "https://github.com/sebastianfeldmann/cli", + "keywords": [ + "cli" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/cli/issues", + "source": "https://github.com/sebastianfeldmann/cli/tree/3.4.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "install-path": "../sebastianfeldmann/cli" + }, + { + "name": "sebastianfeldmann/git", + "version": "3.15.1", + "version_normalized": "3.15.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/git.git", + "reference": "90cb5a32f54dbb0d7dcd87d02e664ec2b50c0c96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/git/zipball/90cb5a32f54dbb0d7dcd87d02e664ec2b50c0c96", + "reference": "90cb5a32f54dbb0d7dcd87d02e664ec2b50c0c96", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "php": ">=8.0", + "sebastianfeldmann/cli": "^3.0" + }, + "require-dev": { + "mikey179/vfsstream": "^1.6" + }, + "time": "2025-09-05T08:07:09+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "SebastianFeldmann\\Git\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git wrapper", + "homepage": "https://github.com/sebastianfeldmann/git", + "keywords": [ + "git" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/git/issues", + "source": "https://github.com/sebastianfeldmann/git/tree/3.15.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "install-path": "../sebastianfeldmann/git" + }, { "name": "soundasleep/html2text", "version": "2.1.0", @@ -4057,6 +4388,74 @@ ], "install-path": "../symfony/polyfill-php83" }, + { + "name": "symfony/process", + "version": "v6.4.26", + "version_normalized": "6.4.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "time": "2025-09-11T09:57:09+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v6.4.26" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/process" + }, { "name": "symfony/routing", "version": "v6.4.2", @@ -5297,7 +5696,13 @@ ], "dev": true, "dev-package-names": [ + "captainhook/captainhook", + "captainhook/secrets", + "sebastianfeldmann/camino", + "sebastianfeldmann/cli", + "sebastianfeldmann/git", "symfony/debug-bundle", + "symfony/process", "symfony/stopwatch", "symfony/web-profiler-bundle" ] diff --git a/lib/composer/installed.php b/lib/composer/installed.php index 1a90038ddd..d64678f2f4 100644 --- a/lib/composer/installed.php +++ b/lib/composer/installed.php @@ -1,707 +1,769 @@ - array( - 'name' => 'combodo/itop', - 'pretty_version' => 'dev-develop', - 'version' => 'dev-develop', - 'reference' => 'c88ba664db4ec5622838a0ee00768e3bc3381d4e', - 'type' => 'project', - 'install_path' => __DIR__ . '/../../', - 'aliases' => array(), - 'dev' => true, - ), - 'versions' => array( - 'apereo/phpcas' => array( - 'pretty_version' => '1.6.1', - 'version' => '1.6.1.0', - 'reference' => 'c129708154852656aabb13d8606cd5b12dbbabac', - 'type' => 'library', - 'install_path' => __DIR__ . '/../apereo/phpcas', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'combodo/itop' => array( - 'pretty_version' => 'dev-develop', - 'version' => 'dev-develop', - 'reference' => 'c88ba664db4ec5622838a0ee00768e3bc3381d4e', - 'type' => 'project', - 'install_path' => __DIR__ . '/../../', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'doctrine/lexer' => array( - 'pretty_version' => '3.0.1', - 'version' => '3.0.1.0', - 'reference' => '31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd', - 'type' => 'library', - 'install_path' => __DIR__ . '/../doctrine/lexer', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'egulias/email-validator' => array( - 'pretty_version' => '4.0.4', - 'version' => '4.0.4.0', - 'reference' => 'd42c8731f0624ad6bdc8d3e5e9a4524f68801cfa', - 'type' => 'library', - 'install_path' => __DIR__ . '/../egulias/email-validator', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'firebase/php-jwt' => array( - 'pretty_version' => 'v6.10.0', - 'version' => '6.10.0.0', - 'reference' => 'a49db6f0a5033aef5143295342f1c95521b075ff', - 'type' => 'library', - 'install_path' => __DIR__ . '/../firebase/php-jwt', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'guzzlehttp/guzzle' => array( - 'pretty_version' => '7.8.1', - 'version' => '7.8.1.0', - 'reference' => '41042bc7ab002487b876a0683fc8dce04ddce104', - 'type' => 'library', - 'install_path' => __DIR__ . '/../guzzlehttp/guzzle', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'guzzlehttp/promises' => array( - 'pretty_version' => '2.0.2', - 'version' => '2.0.2.0', - 'reference' => 'bbff78d96034045e58e13dedd6ad91b5d1253223', - 'type' => 'library', - 'install_path' => __DIR__ . '/../guzzlehttp/promises', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'guzzlehttp/psr7' => array( - 'pretty_version' => '2.6.2', - 'version' => '2.6.2.0', - 'reference' => '45b30f99ac27b5ca93cb4831afe16285f57b8221', - 'type' => 'library', - 'install_path' => __DIR__ . '/../guzzlehttp/psr7', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'league/oauth2-client' => array( - 'pretty_version' => '2.7.0', - 'version' => '2.7.0.0', - 'reference' => '160d6274b03562ebeb55ed18399281d8118b76c8', - 'type' => 'library', - 'install_path' => __DIR__ . '/../league/oauth2-client', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'league/oauth2-google' => array( - 'pretty_version' => '4.0.1', - 'version' => '4.0.1.0', - 'reference' => '1b01ba18ba31b29e88771e3e0979e5c91d4afe76', - 'type' => 'library', - 'install_path' => __DIR__ . '/../league/oauth2-google', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'nikic/php-parser' => array( - 'pretty_version' => 'v4.18.0', - 'version' => '4.18.0.0', - 'reference' => '1bcbb2179f97633e98bbbc87044ee2611c7d7999', - 'type' => 'library', - 'install_path' => __DIR__ . '/../nikic/php-parser', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'paragonie/random_compat' => array( - 'pretty_version' => 'v9.99.100', - 'version' => '9.99.100.0', - 'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a', - 'type' => 'library', - 'install_path' => __DIR__ . '/../paragonie/random_compat', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'pear/archive_tar' => array( - 'pretty_version' => '1.4.14', - 'version' => '1.4.14.0', - 'reference' => '4d761c5334c790e45ef3245f0864b8955c562caa', - 'type' => 'library', - 'install_path' => __DIR__ . '/../pear/archive_tar', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'pear/console_getopt' => array( - 'pretty_version' => 'v1.4.3', - 'version' => '1.4.3.0', - 'reference' => 'a41f8d3e668987609178c7c4a9fe48fecac53fa0', - 'type' => 'library', - 'install_path' => __DIR__ . '/../pear/console_getopt', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'pear/pear-core-minimal' => array( - 'pretty_version' => 'v1.10.11', - 'version' => '1.10.11.0', - 'reference' => '68d0d32ada737153b7e93b8d3c710ebe70ac867d', - 'type' => 'library', - 'install_path' => __DIR__ . '/../pear/pear-core-minimal', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'pear/pear_exception' => array( - 'pretty_version' => 'v1.0.2', - 'version' => '1.0.2.0', - 'reference' => 'b14fbe2ddb0b9f94f5b24cf08783d599f776fff0', - 'type' => 'class', - 'install_path' => __DIR__ . '/../pear/pear_exception', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'pelago/emogrifier' => array( - 'pretty_version' => 'v7.2.0', - 'version' => '7.2.0.0', - 'reference' => '727bdf7255b51798307f17dec52ff8a91f1c7de3', - 'type' => 'library', - 'install_path' => __DIR__ . '/../pelago/emogrifier', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/cache' => array( - 'pretty_version' => '3.0.0', - 'version' => '3.0.0.0', - 'reference' => 'aa5030cfa5405eccfdcb1083ce040c2cb8d253bf', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/cache', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/cache-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '2.0|3.0', - ), - ), - 'psr/container' => array( - 'pretty_version' => '1.1.2', - 'version' => '1.1.2.0', - 'reference' => '513e0666f7216c7459170d56df27dfcefe1689ea', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/container', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/container-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.1|2.0', - ), - ), - 'psr/event-dispatcher' => array( - 'pretty_version' => '1.0.0', - 'version' => '1.0.0.0', - 'reference' => 'dbefd12671e8a14ec7f180cab83036ed26714bb0', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/event-dispatcher', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/event-dispatcher-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.0', - ), - ), - 'psr/http-client' => array( - 'pretty_version' => '1.0.3', - 'version' => '1.0.3.0', - 'reference' => 'bb5906edc1c324c9a05aa0873d40117941e5fa90', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/http-client', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/http-client-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.0', - ), - ), - 'psr/http-factory' => array( - 'pretty_version' => '1.0.2', - 'version' => '1.0.2.0', - 'reference' => 'e616d01114759c4c489f93b099585439f795fe35', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/http-factory', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/http-factory-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.0', - ), - ), - 'psr/http-message' => array( - 'pretty_version' => '2.0', - 'version' => '2.0.0.0', - 'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/http-message', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/http-message-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.0', - ), - ), - 'psr/log' => array( - 'pretty_version' => '3.0.0', - 'version' => '3.0.0.0', - 'reference' => 'fe5ea303b0887d5caefd3d431c3e61ad47037001', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/log', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'psr/log-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.0|2.0|3.0', - ), - ), - 'psr/simple-cache-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.0|2.0|3.0', - ), - ), - 'ralouphie/getallheaders' => array( - 'pretty_version' => '3.0.3', - 'version' => '3.0.3.0', - 'reference' => '120b605dfeb996808c31b6477290a714d356e822', - 'type' => 'library', - 'install_path' => __DIR__ . '/../ralouphie/getallheaders', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'rsky/pear-core-min' => array( - 'dev_requirement' => false, - 'replaced' => array( - 0 => 'v1.10.11', - ), - ), - 'sabberworm/php-css-parser' => array( - 'pretty_version' => '8.4.0', - 'version' => '8.4.0.0', - 'reference' => 'e41d2140031d533348b2192a83f02d8dd8a71d30', - 'type' => 'library', - 'install_path' => __DIR__ . '/../sabberworm/php-css-parser', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'scssphp/scssphp' => array( - 'pretty_version' => 'v1.12.1', - 'version' => '1.12.1.0', - 'reference' => '394ed1e960138710a60d035c1a85d43d0bf0faeb', - 'type' => 'library', - 'install_path' => __DIR__ . '/../scssphp/scssphp', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'soundasleep/html2text' => array( - 'pretty_version' => '2.1.0', - 'version' => '2.1.0.0', - 'reference' => '83502b6f8f1aaef8e2e238897199d64f284b4af3', - 'type' => 'library', - 'install_path' => __DIR__ . '/../soundasleep/html2text', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/cache' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '14a75869bbb41cb35bc5d9d322473928c6f3f978', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/cache', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/cache-contracts' => array( - 'pretty_version' => 'v3.4.0', - 'version' => '3.4.0.0', - 'reference' => '1d74b127da04ffa87aa940abe15446fa89653778', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/cache-contracts', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/cache-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.1|2.0|3.0', - ), - ), - 'symfony/config' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => '5d33e0fb707d603330e0edfd4691803a1253572e', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/config', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/console' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '0254811a143e6bc6c8deea08b589a7e68a37f625', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/console', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/css-selector' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => 'd036c6c0d0b09e24a14a35f8292146a658f986e4', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/css-selector', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/debug-bundle' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => '1e07027423d1d37125b60a50997ada26a9d9d202', - 'type' => 'symfony-bundle', - 'install_path' => __DIR__ . '/../symfony/debug-bundle', - 'aliases' => array(), - 'dev_requirement' => true, - ), - 'symfony/dependency-injection' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '226ea431b1eda6f0d9f5a4b278757171960bb195', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/dependency-injection', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/deprecation-contracts' => array( - 'pretty_version' => 'v3.6.0', - 'version' => '3.6.0.0', - 'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/deprecation-contracts', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/dotenv' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '835f8d2d1022934ac038519de40b88158798c96f', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/dotenv', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/error-handler' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => 'c873490a1c97b3a0a4838afc36ff36c112d02788', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/error-handler', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/event-dispatcher' => array( - 'pretty_version' => 'v6.4.25', - 'version' => '6.4.25.0', - 'reference' => 'b0cf3162020603587363f0551cd3be43958611ff', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/event-dispatcher', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/event-dispatcher-contracts' => array( - 'pretty_version' => 'v3.6.0', - 'version' => '3.6.0.0', - 'reference' => '59eb412e93815df44f05f342958efa9f46b1e586', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/event-dispatcher-contracts', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/event-dispatcher-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '2.0|3.0', - ), - ), - 'symfony/filesystem' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => '952a8cb588c3bc6ce76f6023000fb932f16a6e59', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/filesystem', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/finder' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => '11d736e97f116ac375a81f96e662911a34cd50ce', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/finder', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/framework-bundle' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => 'c26a221e0462027d1f9d4a802ed63f8ab07a43d0', - 'type' => 'symfony-bundle', - 'install_path' => __DIR__ . '/../symfony/framework-bundle', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/http-foundation' => array( - 'pretty_version' => 'v6.4.14', - 'version' => '6.4.14.0', - 'reference' => 'ba020a321a95519303a3f09ec2824d34d601c388', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/http-foundation', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/http-kernel' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '13e8387320b5942d0dc408440c888e2d526efef4', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/http-kernel', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/mailer' => array( - 'pretty_version' => 'v6.4.25', - 'version' => '6.4.25.0', - 'reference' => '628b43b45a3e6b15c8a633fb22df547ed9b492a2', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/mailer', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/mime' => array( - 'pretty_version' => 'v6.4.24', - 'version' => '6.4.24.0', - 'reference' => '664d5e844a2de5e11c8255d0aef6bc15a9660ac7', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/mime', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/polyfill-ctype' => array( - 'pretty_version' => 'v1.28.0', - 'version' => '1.28.0.0', - 'reference' => 'ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/polyfill-ctype', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/polyfill-intl-grapheme' => array( - 'pretty_version' => 'v1.28.0', - 'version' => '1.28.0.0', - 'reference' => '875e90aeea2777b6f135677f618529449334a612', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/polyfill-intl-grapheme', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/polyfill-intl-idn' => array( - 'pretty_version' => 'v1.33.0', - 'version' => '1.33.0.0', - 'reference' => '9614ac4d8061dc257ecc64cba1b140873dce8ad3', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/polyfill-intl-idn', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/polyfill-intl-normalizer' => array( - 'pretty_version' => 'v1.33.0', - 'version' => '1.33.0.0', - 'reference' => '3833d7255cc303546435cb650316bff708a1c75c', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/polyfill-intl-normalizer', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/polyfill-mbstring' => array( - 'pretty_version' => 'v1.33.0', - 'version' => '1.33.0.0', - 'reference' => '6d857f4d76bd4b343eac26d6b539585d2bc56493', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/polyfill-mbstring', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/polyfill-php80' => array( - 'pretty_version' => 'v1.33.0', - 'version' => '1.33.0.0', - 'reference' => '0cc9dd0f17f61d8131e7df6b84bd344899fe2608', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/polyfill-php80', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/polyfill-php83' => array( - 'pretty_version' => 'v1.28.0', - 'version' => '1.28.0.0', - 'reference' => 'b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/polyfill-php83', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/routing' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '98eab13a07fddc85766f1756129c69f207ffbc21', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/routing', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/runtime' => array( - 'pretty_version' => 'v6.4.24', - 'version' => '6.4.24.0', - 'reference' => 'c1cc6721646f546627236c57f835272806087337', - 'type' => 'composer-plugin', - 'install_path' => __DIR__ . '/../symfony/runtime', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/service-contracts' => array( - 'pretty_version' => 'v3.6.0', - 'version' => '3.6.0.0', - 'reference' => 'f021b05a130d35510bd6b25fe9053c2a8a15d5d4', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/service-contracts', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/service-implementation' => array( - 'dev_requirement' => false, - 'provided' => array( - 0 => '1.1|2.0|3.0', - ), - ), - 'symfony/stopwatch' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => 'fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/stopwatch', - 'aliases' => array(), - 'dev_requirement' => true, - ), - 'symfony/string' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '7cb80bc10bfcdf6b5492741c0b9357dac66940bc', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/string', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/translation-contracts' => array( - 'pretty_version' => 'v3.4.0', - 'version' => '3.4.0.0', - 'reference' => 'dee0c6e5b4c07ce851b462530088e64b255ac9c5', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/translation-contracts', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/twig-bridge' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => '142bc3ad4a61d7eedf7cc21d8ef2bd8a8e7417bf', - 'type' => 'symfony-bridge', - 'install_path' => __DIR__ . '/../symfony/twig-bridge', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/twig-bundle' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => '35d84393e598dfb774e6a2bf49e5229a8a6dbe4c', - 'type' => 'symfony-bundle', - 'install_path' => __DIR__ . '/../symfony/twig-bundle', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/var-dumper' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '68d6573ec98715ddcae5a0a85bee3c1c27a4c33f', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/var-dumper', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/var-exporter' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '5fe9a0021b8d35e67d914716ec8de50716a68e7e', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/var-exporter', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'symfony/web-profiler-bundle' => array( - 'pretty_version' => 'v6.4.2', - 'version' => '6.4.2.0', - 'reference' => '38462d16856740ec0d1ba2cb902eebf09100dde2', - 'type' => 'symfony-bundle', - 'install_path' => __DIR__ . '/../symfony/web-profiler-bundle', - 'aliases' => array(), - 'dev_requirement' => true, - ), - 'symfony/yaml' => array( - 'pretty_version' => 'v6.4.0', - 'version' => '6.4.0.0', - 'reference' => '4f9237a1bb42455d609e6687d2613dde5b41a587', - 'type' => 'library', - 'install_path' => __DIR__ . '/../symfony/yaml', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'tecnickcom/tcpdf' => array( - 'pretty_version' => '6.10.0', - 'version' => '6.10.0.0', - 'reference' => 'ca5b6de294512145db96bcbc94e61696599c391d', - 'type' => 'library', - 'install_path' => __DIR__ . '/../tecnickcom/tcpdf', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'thenetworg/oauth2-azure' => array( - 'pretty_version' => 'v2.2.2', - 'version' => '2.2.2.0', - 'reference' => 'be204a5135f016470a9c33e82ab48785bbc11af2', - 'type' => 'library', - 'install_path' => __DIR__ . '/../thenetworg/oauth2-azure', - 'aliases' => array(), - 'dev_requirement' => false, - ), - 'twig/twig' => array( - 'pretty_version' => 'v3.21.1', - 'version' => '3.21.1.0', - 'reference' => '285123877d4dd97dd7c11842ac5fb7e86e60d81d', - 'type' => 'library', - 'install_path' => __DIR__ . '/../twig/twig', - 'aliases' => array(), - 'dev_requirement' => false, - ), - ), -); + [ + 'name' => 'combodo/itop', + 'pretty_version' => 'dev-develop', + 'version' => 'dev-develop', + 'reference' => '80d4e65a811bcaf8372a4f4985c814929ddaea5e', + 'type' => 'project', + 'install_path' => __DIR__.'/../../', + 'aliases' => [], + 'dev' => true, + ], + 'versions' => [ + 'apereo/phpcas' => [ + 'pretty_version' => '1.6.1', + 'version' => '1.6.1.0', + 'reference' => 'c129708154852656aabb13d8606cd5b12dbbabac', + 'type' => 'library', + 'install_path' => __DIR__.'/../apereo/phpcas', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'captainhook/captainhook' => [ + 'pretty_version' => '5.25.11', + 'version' => '5.25.11.0', + 'reference' => 'f2278edde4b45af353861aae413fc3840515bb80', + 'type' => 'library', + 'install_path' => __DIR__.'/../captainhook/captainhook', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'captainhook/secrets' => [ + 'pretty_version' => '0.9.7', + 'version' => '0.9.7.0', + 'reference' => 'd62c97f75f81ac98e22f1c282482bd35fa82f631', + 'type' => 'library', + 'install_path' => __DIR__.'/../captainhook/secrets', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'combodo/itop' => [ + 'pretty_version' => 'dev-develop', + 'version' => 'dev-develop', + 'reference' => '80d4e65a811bcaf8372a4f4985c814929ddaea5e', + 'type' => 'project', + 'install_path' => __DIR__.'/../../', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'doctrine/lexer' => [ + 'pretty_version' => '3.0.1', + 'version' => '3.0.1.0', + 'reference' => '31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd', + 'type' => 'library', + 'install_path' => __DIR__.'/../doctrine/lexer', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'egulias/email-validator' => [ + 'pretty_version' => '4.0.4', + 'version' => '4.0.4.0', + 'reference' => 'd42c8731f0624ad6bdc8d3e5e9a4524f68801cfa', + 'type' => 'library', + 'install_path' => __DIR__.'/../egulias/email-validator', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'firebase/php-jwt' => [ + 'pretty_version' => 'v6.10.0', + 'version' => '6.10.0.0', + 'reference' => 'a49db6f0a5033aef5143295342f1c95521b075ff', + 'type' => 'library', + 'install_path' => __DIR__.'/../firebase/php-jwt', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'guzzlehttp/guzzle' => [ + 'pretty_version' => '7.8.1', + 'version' => '7.8.1.0', + 'reference' => '41042bc7ab002487b876a0683fc8dce04ddce104', + 'type' => 'library', + 'install_path' => __DIR__.'/../guzzlehttp/guzzle', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'guzzlehttp/promises' => [ + 'pretty_version' => '2.0.2', + 'version' => '2.0.2.0', + 'reference' => 'bbff78d96034045e58e13dedd6ad91b5d1253223', + 'type' => 'library', + 'install_path' => __DIR__.'/../guzzlehttp/promises', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'guzzlehttp/psr7' => [ + 'pretty_version' => '2.6.2', + 'version' => '2.6.2.0', + 'reference' => '45b30f99ac27b5ca93cb4831afe16285f57b8221', + 'type' => 'library', + 'install_path' => __DIR__.'/../guzzlehttp/psr7', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'league/oauth2-client' => [ + 'pretty_version' => '2.7.0', + 'version' => '2.7.0.0', + 'reference' => '160d6274b03562ebeb55ed18399281d8118b76c8', + 'type' => 'library', + 'install_path' => __DIR__.'/../league/oauth2-client', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'league/oauth2-google' => [ + 'pretty_version' => '4.0.1', + 'version' => '4.0.1.0', + 'reference' => '1b01ba18ba31b29e88771e3e0979e5c91d4afe76', + 'type' => 'library', + 'install_path' => __DIR__.'/../league/oauth2-google', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'nikic/php-parser' => [ + 'pretty_version' => 'v4.18.0', + 'version' => '4.18.0.0', + 'reference' => '1bcbb2179f97633e98bbbc87044ee2611c7d7999', + 'type' => 'library', + 'install_path' => __DIR__.'/../nikic/php-parser', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'paragonie/random_compat' => [ + 'pretty_version' => 'v9.99.100', + 'version' => '9.99.100.0', + 'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a', + 'type' => 'library', + 'install_path' => __DIR__.'/../paragonie/random_compat', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'pear/archive_tar' => [ + 'pretty_version' => '1.4.14', + 'version' => '1.4.14.0', + 'reference' => '4d761c5334c790e45ef3245f0864b8955c562caa', + 'type' => 'library', + 'install_path' => __DIR__.'/../pear/archive_tar', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'pear/console_getopt' => [ + 'pretty_version' => 'v1.4.3', + 'version' => '1.4.3.0', + 'reference' => 'a41f8d3e668987609178c7c4a9fe48fecac53fa0', + 'type' => 'library', + 'install_path' => __DIR__.'/../pear/console_getopt', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'pear/pear-core-minimal' => [ + 'pretty_version' => 'v1.10.11', + 'version' => '1.10.11.0', + 'reference' => '68d0d32ada737153b7e93b8d3c710ebe70ac867d', + 'type' => 'library', + 'install_path' => __DIR__.'/../pear/pear-core-minimal', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'pear/pear_exception' => [ + 'pretty_version' => 'v1.0.2', + 'version' => '1.0.2.0', + 'reference' => 'b14fbe2ddb0b9f94f5b24cf08783d599f776fff0', + 'type' => 'class', + 'install_path' => __DIR__.'/../pear/pear_exception', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'pelago/emogrifier' => [ + 'pretty_version' => 'v7.2.0', + 'version' => '7.2.0.0', + 'reference' => '727bdf7255b51798307f17dec52ff8a91f1c7de3', + 'type' => 'library', + 'install_path' => __DIR__.'/../pelago/emogrifier', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/cache' => [ + 'pretty_version' => '3.0.0', + 'version' => '3.0.0.0', + 'reference' => 'aa5030cfa5405eccfdcb1083ce040c2cb8d253bf', + 'type' => 'library', + 'install_path' => __DIR__.'/../psr/cache', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/cache-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '2.0|3.0', + ], + ], + 'psr/container' => [ + 'pretty_version' => '1.1.2', + 'version' => '1.1.2.0', + 'reference' => '513e0666f7216c7459170d56df27dfcefe1689ea', + 'type' => 'library', + 'install_path' => __DIR__.'/../psr/container', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/container-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.1|2.0', + ], + ], + 'psr/event-dispatcher' => [ + 'pretty_version' => '1.0.0', + 'version' => '1.0.0.0', + 'reference' => 'dbefd12671e8a14ec7f180cab83036ed26714bb0', + 'type' => 'library', + 'install_path' => __DIR__.'/../psr/event-dispatcher', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/event-dispatcher-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.0', + ], + ], + 'psr/http-client' => [ + 'pretty_version' => '1.0.3', + 'version' => '1.0.3.0', + 'reference' => 'bb5906edc1c324c9a05aa0873d40117941e5fa90', + 'type' => 'library', + 'install_path' => __DIR__.'/../psr/http-client', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/http-client-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.0', + ], + ], + 'psr/http-factory' => [ + 'pretty_version' => '1.0.2', + 'version' => '1.0.2.0', + 'reference' => 'e616d01114759c4c489f93b099585439f795fe35', + 'type' => 'library', + 'install_path' => __DIR__.'/../psr/http-factory', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/http-factory-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.0', + ], + ], + 'psr/http-message' => [ + 'pretty_version' => '2.0', + 'version' => '2.0.0.0', + 'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71', + 'type' => 'library', + 'install_path' => __DIR__.'/../psr/http-message', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/http-message-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.0', + ], + ], + 'psr/log' => [ + 'pretty_version' => '3.0.0', + 'version' => '3.0.0.0', + 'reference' => 'fe5ea303b0887d5caefd3d431c3e61ad47037001', + 'type' => 'library', + 'install_path' => __DIR__.'/../psr/log', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'psr/log-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.0|2.0|3.0', + ], + ], + 'psr/simple-cache-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.0|2.0|3.0', + ], + ], + 'ralouphie/getallheaders' => [ + 'pretty_version' => '3.0.3', + 'version' => '3.0.3.0', + 'reference' => '120b605dfeb996808c31b6477290a714d356e822', + 'type' => 'library', + 'install_path' => __DIR__.'/../ralouphie/getallheaders', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'rsky/pear-core-min' => [ + 'dev_requirement' => false, + 'replaced' => [ + 0 => 'v1.10.11', + ], + ], + 'sabberworm/php-css-parser' => [ + 'pretty_version' => '8.4.0', + 'version' => '8.4.0.0', + 'reference' => 'e41d2140031d533348b2192a83f02d8dd8a71d30', + 'type' => 'library', + 'install_path' => __DIR__.'/../sabberworm/php-css-parser', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'scssphp/scssphp' => [ + 'pretty_version' => 'v1.12.1', + 'version' => '1.12.1.0', + 'reference' => '394ed1e960138710a60d035c1a85d43d0bf0faeb', + 'type' => 'library', + 'install_path' => __DIR__.'/../scssphp/scssphp', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'sebastianfeldmann/camino' => [ + 'pretty_version' => '0.9.5', + 'version' => '0.9.5.0', + 'reference' => 'bf2e4c8b2a029e9eade43666132b61331e3e8184', + 'type' => 'library', + 'install_path' => __DIR__.'/../sebastianfeldmann/camino', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'sebastianfeldmann/captainhook' => [ + 'dev_requirement' => true, + 'replaced' => [ + 0 => '*', + ], + ], + 'sebastianfeldmann/cli' => [ + 'pretty_version' => '3.4.2', + 'version' => '3.4.2.0', + 'reference' => '6fa122afd528dae7d7ec988a604aa6c600f5d9b5', + 'type' => 'library', + 'install_path' => __DIR__.'/../sebastianfeldmann/cli', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'sebastianfeldmann/git' => [ + 'pretty_version' => '3.15.1', + 'version' => '3.15.1.0', + 'reference' => '90cb5a32f54dbb0d7dcd87d02e664ec2b50c0c96', + 'type' => 'library', + 'install_path' => __DIR__.'/../sebastianfeldmann/git', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'soundasleep/html2text' => [ + 'pretty_version' => '2.1.0', + 'version' => '2.1.0.0', + 'reference' => '83502b6f8f1aaef8e2e238897199d64f284b4af3', + 'type' => 'library', + 'install_path' => __DIR__.'/../soundasleep/html2text', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/cache' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '14a75869bbb41cb35bc5d9d322473928c6f3f978', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/cache', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/cache-contracts' => [ + 'pretty_version' => 'v3.4.0', + 'version' => '3.4.0.0', + 'reference' => '1d74b127da04ffa87aa940abe15446fa89653778', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/cache-contracts', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/cache-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.1|2.0|3.0', + ], + ], + 'symfony/config' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => '5d33e0fb707d603330e0edfd4691803a1253572e', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/config', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/console' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '0254811a143e6bc6c8deea08b589a7e68a37f625', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/console', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/css-selector' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => 'd036c6c0d0b09e24a14a35f8292146a658f986e4', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/css-selector', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/debug-bundle' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => '1e07027423d1d37125b60a50997ada26a9d9d202', + 'type' => 'symfony-bundle', + 'install_path' => __DIR__.'/../symfony/debug-bundle', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'symfony/dependency-injection' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '226ea431b1eda6f0d9f5a4b278757171960bb195', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/dependency-injection', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/deprecation-contracts' => [ + 'pretty_version' => 'v3.6.0', + 'version' => '3.6.0.0', + 'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/deprecation-contracts', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/dotenv' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '835f8d2d1022934ac038519de40b88158798c96f', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/dotenv', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/error-handler' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => 'c873490a1c97b3a0a4838afc36ff36c112d02788', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/error-handler', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/event-dispatcher' => [ + 'pretty_version' => 'v6.4.25', + 'version' => '6.4.25.0', + 'reference' => 'b0cf3162020603587363f0551cd3be43958611ff', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/event-dispatcher', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/event-dispatcher-contracts' => [ + 'pretty_version' => 'v3.6.0', + 'version' => '3.6.0.0', + 'reference' => '59eb412e93815df44f05f342958efa9f46b1e586', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/event-dispatcher-contracts', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/event-dispatcher-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '2.0|3.0', + ], + ], + 'symfony/filesystem' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => '952a8cb588c3bc6ce76f6023000fb932f16a6e59', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/filesystem', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/finder' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => '11d736e97f116ac375a81f96e662911a34cd50ce', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/finder', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/framework-bundle' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => 'c26a221e0462027d1f9d4a802ed63f8ab07a43d0', + 'type' => 'symfony-bundle', + 'install_path' => __DIR__.'/../symfony/framework-bundle', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/http-foundation' => [ + 'pretty_version' => 'v6.4.14', + 'version' => '6.4.14.0', + 'reference' => 'ba020a321a95519303a3f09ec2824d34d601c388', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/http-foundation', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/http-kernel' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '13e8387320b5942d0dc408440c888e2d526efef4', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/http-kernel', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/mailer' => [ + 'pretty_version' => 'v6.4.25', + 'version' => '6.4.25.0', + 'reference' => '628b43b45a3e6b15c8a633fb22df547ed9b492a2', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/mailer', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/mime' => [ + 'pretty_version' => 'v6.4.24', + 'version' => '6.4.24.0', + 'reference' => '664d5e844a2de5e11c8255d0aef6bc15a9660ac7', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/mime', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/polyfill-ctype' => [ + 'pretty_version' => 'v1.28.0', + 'version' => '1.28.0.0', + 'reference' => 'ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/polyfill-ctype', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/polyfill-intl-grapheme' => [ + 'pretty_version' => 'v1.28.0', + 'version' => '1.28.0.0', + 'reference' => '875e90aeea2777b6f135677f618529449334a612', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/polyfill-intl-grapheme', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/polyfill-intl-idn' => [ + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => '9614ac4d8061dc257ecc64cba1b140873dce8ad3', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/polyfill-intl-idn', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/polyfill-intl-normalizer' => [ + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => '3833d7255cc303546435cb650316bff708a1c75c', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/polyfill-intl-normalizer', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/polyfill-mbstring' => [ + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => '6d857f4d76bd4b343eac26d6b539585d2bc56493', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/polyfill-mbstring', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/polyfill-php80' => [ + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => '0cc9dd0f17f61d8131e7df6b84bd344899fe2608', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/polyfill-php80', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/polyfill-php83' => [ + 'pretty_version' => 'v1.28.0', + 'version' => '1.28.0.0', + 'reference' => 'b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/polyfill-php83', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/process' => [ + 'pretty_version' => 'v6.4.26', + 'version' => '6.4.26.0', + 'reference' => '48bad913268c8cafabbf7034b39c8bb24fbc5ab8', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/process', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'symfony/routing' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '98eab13a07fddc85766f1756129c69f207ffbc21', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/routing', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/runtime' => [ + 'pretty_version' => 'v6.4.24', + 'version' => '6.4.24.0', + 'reference' => 'c1cc6721646f546627236c57f835272806087337', + 'type' => 'composer-plugin', + 'install_path' => __DIR__.'/../symfony/runtime', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/service-contracts' => [ + 'pretty_version' => 'v3.6.0', + 'version' => '3.6.0.0', + 'reference' => 'f021b05a130d35510bd6b25fe9053c2a8a15d5d4', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/service-contracts', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/service-implementation' => [ + 'dev_requirement' => false, + 'provided' => [ + 0 => '1.1|2.0|3.0', + ], + ], + 'symfony/stopwatch' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => 'fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/stopwatch', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'symfony/string' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '7cb80bc10bfcdf6b5492741c0b9357dac66940bc', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/string', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/translation-contracts' => [ + 'pretty_version' => 'v3.4.0', + 'version' => '3.4.0.0', + 'reference' => 'dee0c6e5b4c07ce851b462530088e64b255ac9c5', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/translation-contracts', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/twig-bridge' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => '142bc3ad4a61d7eedf7cc21d8ef2bd8a8e7417bf', + 'type' => 'symfony-bridge', + 'install_path' => __DIR__.'/../symfony/twig-bridge', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/twig-bundle' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => '35d84393e598dfb774e6a2bf49e5229a8a6dbe4c', + 'type' => 'symfony-bundle', + 'install_path' => __DIR__.'/../symfony/twig-bundle', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/var-dumper' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '68d6573ec98715ddcae5a0a85bee3c1c27a4c33f', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/var-dumper', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/var-exporter' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '5fe9a0021b8d35e67d914716ec8de50716a68e7e', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/var-exporter', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'symfony/web-profiler-bundle' => [ + 'pretty_version' => 'v6.4.2', + 'version' => '6.4.2.0', + 'reference' => '38462d16856740ec0d1ba2cb902eebf09100dde2', + 'type' => 'symfony-bundle', + 'install_path' => __DIR__.'/../symfony/web-profiler-bundle', + 'aliases' => [], + 'dev_requirement' => true, + ], + 'symfony/yaml' => [ + 'pretty_version' => 'v6.4.0', + 'version' => '6.4.0.0', + 'reference' => '4f9237a1bb42455d609e6687d2613dde5b41a587', + 'type' => 'library', + 'install_path' => __DIR__.'/../symfony/yaml', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'tecnickcom/tcpdf' => [ + 'pretty_version' => '6.10.0', + 'version' => '6.10.0.0', + 'reference' => 'ca5b6de294512145db96bcbc94e61696599c391d', + 'type' => 'library', + 'install_path' => __DIR__.'/../tecnickcom/tcpdf', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'thenetworg/oauth2-azure' => [ + 'pretty_version' => 'v2.2.2', + 'version' => '2.2.2.0', + 'reference' => 'be204a5135f016470a9c33e82ab48785bbc11af2', + 'type' => 'library', + 'install_path' => __DIR__.'/../thenetworg/oauth2-azure', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'twig/twig' => [ + 'pretty_version' => 'v3.21.1', + 'version' => '3.21.1.0', + 'reference' => '285123877d4dd97dd7c11842ac5fb7e86e60d81d', + 'type' => 'library', + 'install_path' => __DIR__.'/../twig/twig', + 'aliases' => [], + 'dev_requirement' => false, + ], + ], +]; diff --git a/lib/composer/platform_check.php b/lib/composer/platform_check.php index 72145773d0..890928c739 100644 --- a/lib/composer/platform_check.php +++ b/lib/composer/platform_check.php @@ -2,13 +2,13 @@ // platform_check.php @generated by Composer -$issues = array(); +$issues = []; if (!(PHP_VERSION_ID >= 80100)) { - $issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.'; + $issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running '.PHP_VERSION.'.'; } -$missingExtensions = array(); +$missingExtensions = []; extension_loaded('curl') || $missingExtensions[] = 'curl'; extension_loaded('dom') || $missingExtensions[] = 'dom'; extension_loaded('gd') || $missingExtensions[] = 'gd'; @@ -22,21 +22,22 @@ extension_loaded('xml') || $missingExtensions[] = 'xml'; if ($missingExtensions) { - $issues[] = 'Your Composer dependencies require the following PHP extensions to be installed: ' . implode(', ', $missingExtensions) . '.'; + $issues[] = 'Your Composer dependencies require the following PHP extensions to be installed: '.implode(', ', $missingExtensions).'.'; } if ($issues) { - if (!headers_sent()) { - header('HTTP/1.1 500 Internal Server Error'); - } - if (!ini_get('display_errors')) { - if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { - fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL); - } elseif (!headers_sent()) { - echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; - } - } - throw new \RuntimeException( - 'Composer detected issues in your platform: ' . implode(' ', $issues) - ); + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, 'Composer detected issues in your platform:'.PHP_EOL.PHP_EOL.implode(PHP_EOL, $issues).PHP_EOL.PHP_EOL); + } elseif (!headers_sent()) { + echo 'Composer detected issues in your platform:'.PHP_EOL.PHP_EOL.str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)).PHP_EOL.PHP_EOL; + } + } + trigger_error( + 'Composer detected issues in your platform: '.implode(' ', $issues), + E_USER_ERROR + ); } diff --git a/lib/sebastianfeldmann/camino/.github/FUNDING.yml b/lib/sebastianfeldmann/camino/.github/FUNDING.yml new file mode 100644 index 0000000000..c67f279580 --- /dev/null +++ b/lib/sebastianfeldmann/camino/.github/FUNDING.yml @@ -0,0 +1 @@ +github: sebastianfeldmann diff --git a/lib/sebastianfeldmann/camino/.github/workflows/integration.yml b/lib/sebastianfeldmann/camino/.github/workflows/integration.yml new file mode 100644 index 0000000000..ee4900ad7d --- /dev/null +++ b/lib/sebastianfeldmann/camino/.github/workflows/integration.yml @@ -0,0 +1,44 @@ +name: "CI-Build" + +on: [push, pull_request] + +jobs: + build: + runs-on: ${{ matrix.operating-system }} + strategy: + max-parallel: 3 + matrix: + operating-system: [ubuntu-latest] + php-versions: ['7.3', '7.4', '8.0'] + steps: + - uses: actions/checkout@master + + - name: Install PHP + uses: shivammathur/setup-php@master + with: + php-version: ${{ matrix.php-versions }} + extensions: xdebug + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Install phive + run: wget -O phive.phar https://phar.io/releases/phive.phar + + - name: Make phive executable + run: chmod +x ./phive.phar + + - name: Install tooling + run: ./phive.phar --no-progress --home ./.phive install --trust-gpg-keys 4AA394086372C20A,31C7E470E2138192,8E730BA25823D8B5,CF1A108D0E7AE720 --force-accept-unsigned + + - name: Execute unit tests + run: tools/phpunit + + - name: Check coding style + run: tools/phpcs --standard=psr12 src tests + + - name: Static code analysis + run: tools/phpstan analyse diff --git a/lib/sebastianfeldmann/camino/.scrutinizer.yml b/lib/sebastianfeldmann/camino/.scrutinizer.yml new file mode 100644 index 0000000000..79a059c9bd --- /dev/null +++ b/lib/sebastianfeldmann/camino/.scrutinizer.yml @@ -0,0 +1,30 @@ +build: + environment: + php: + version: '7.3' + dependencies: + before: + - wget -O composer.phar https://getcomposer.org/composer.phar + - chmod +x composer.phar + - ./composer.phar install + tests: + override: + - command: ./vendor/bin/phpunit --coverage-clover=clover.xml + coverage: + file: clover.xml + format: php-clover + nodes: + analysis: + tests: + override: + - php-scrutinizer-run +coding_style: + php: + spaces: + around_operators: + concatenation: true + ternary_operator: + in_short_version: false + other: + after_type_cast: true + diff --git a/lib/sebastianfeldmann/camino/LICENSE b/lib/sebastianfeldmann/camino/LICENSE new file mode 100644 index 0000000000..eac0fcafb7 --- /dev/null +++ b/lib/sebastianfeldmann/camino/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Sebastian Feldmann + +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/lib/sebastianfeldmann/camino/composer.json b/lib/sebastianfeldmann/camino/composer.json new file mode 100644 index 0000000000..02936c0739 --- /dev/null +++ b/lib/sebastianfeldmann/camino/composer.json @@ -0,0 +1,35 @@ +{ + "name": "sebastianfeldmann/camino", + "type": "library", + "description": "Path management the OO way", + "keywords": ["file system", "path"], + "homepage": "https://github.com/sebastianfeldmann/camino", + "license": "MIT", + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/camino/issues" + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Camino\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SebastianFeldmann\\Camino\\": "tests/Camino/" + } + }, + "require": { + "php": ">=7.1" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + } +} diff --git a/lib/sebastianfeldmann/camino/phive.xml b/lib/sebastianfeldmann/camino/phive.xml new file mode 100644 index 0000000000..cf55708543 --- /dev/null +++ b/lib/sebastianfeldmann/camino/phive.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/sebastianfeldmann/camino/phpstan.neon b/lib/sebastianfeldmann/camino/phpstan.neon new file mode 100644 index 0000000000..f4476cb254 --- /dev/null +++ b/lib/sebastianfeldmann/camino/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 5 + paths: + - %currentWorkingDirectory%/src/ diff --git a/lib/sebastianfeldmann/camino/src/Check.php b/lib/sebastianfeldmann/camino/src/Check.php new file mode 100644 index 0000000000..c78f2b97b8 --- /dev/null +++ b/lib/sebastianfeldmann/camino/src/Check.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Camino; + +/** + * Class Util + * + * @package SebastianFeldmann\Camino + */ +abstract class Check +{ + /** + * Is given path absolute? + * + * @param string $path + * @return bool + */ + public static function isAbsolutePath(string $path): bool + { + // path already absolute? + if (substr($path, 0, 1) === '/') { + return true; + } + if (self::isStream($path)) { + return true; + } + if (self::isAbsoluteWindowsPath($path)) { + return true; + } + return false; + } + + /** + * Is given path a stream reference? + * + * @param string $path + * @return bool + */ + public static function isStream(string $path): bool + { + return strpos($path, '://') !== false; + } + + /** + * Is given path an absolute windows path? + * + * matches the following on Windows: + * - \\NetworkComputer\Path + * - \\.\D: + * - \\.\c: + * - C:\Windows + * - C:\windows + * - C:/windows + * - c:/windows + * + * @param string $path + * @return bool + */ + public static function isAbsoluteWindowsPath(string $path): bool + { + return ($path[0] === '\\' || (strlen($path) >= 3 && preg_match('#^[A-Z]\:[/\\\]#i', substr($path, 0, 3)))); + } +} diff --git a/lib/sebastianfeldmann/camino/src/Path.php b/lib/sebastianfeldmann/camino/src/Path.php new file mode 100644 index 0000000000..c1f4baf9de --- /dev/null +++ b/lib/sebastianfeldmann/camino/src/Path.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Camino; + +use SebastianFeldmann\Camino\Path\Directory; + +/** + * Interface Path + * + * @package SebastianFeldmann\Camino + */ +interface Path +{ + /** + * Returns the full absolute path + * + * @return string + */ + public function getPath(): string; + + /** + * Returns the root of a path e.g. /, c:/, stream:// + * + * @return string + */ + public function getRoot(): string; + + /** + * Returns the amount of path segments + * + * @return int + */ + public function getDepth(): int; + + /** + * Returns the list of path segments + * + * @return array + */ + public function getSegments(): array; + + + /** + * Returns the relative path from a given directory + * + * @param \SebastianFeldmann\Camino\Path\Directory $directory + * @return string + */ + public function getRelativePathFrom(Directory $directory): string; + + /** + * Is the given directory a parent directory + * @param \SebastianFeldmann\Camino\Path\Directory $path + * @return bool + */ + public function isChildOf(Directory $path): bool; +} diff --git a/lib/sebastianfeldmann/camino/src/Path/Base.php b/lib/sebastianfeldmann/camino/src/Path/Base.php new file mode 100644 index 0000000000..0a25c42040 --- /dev/null +++ b/lib/sebastianfeldmann/camino/src/Path/Base.php @@ -0,0 +1,286 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Camino\Path; + +use RuntimeException; +use SebastianFeldmann\Camino\Path; + +/** + * Base path class for files and directories + * + * @package SebastianFeldmann\Camino + */ +abstract class Base implements Path +{ + /** + * The originally given path + * + * @var string + */ + protected $raw; + + /** + * The path without the root (/, C:/, stream://) + * + * @var string + */ + protected $path; + + /** + * Amount of path segments + * + * @var int + */ + protected $depth; + + /** + * List of path segments + * + * @var string[] + */ + protected $segments; + + /** + * The path root (/, C:/, stream://) + * + * @var string + */ + protected $root; + + /** + * Absolute constructor. + * + * @param string $path + */ + public function __construct(string $path) + { + $this->raw = $path; + $this->normalize($path); + } + + /** + * Normalize a path detect root and segments + * + * @param string $path + */ + private function normalize(string $path): void + { + // absolute linux|unix path + if (substr($path, 0, 1) === '/') { + $this->root = '/'; + $this->path = ltrim($path, '/'); + $this->detectSegments($this->path); + return; + } + + // check streams + if ($this->normalizeStream($path)) { + return; + } + + // check windows path + if ($this->normalizeWindows($path)) { + return; + } + + throw new RuntimeException('path must be absolute'); + } + + /** + * Normalize a windows path + * + * @param string $path + * @return bool + */ + private function normalizeWindows(string $path): bool + { + // check for C:\ or C:/ + $driveMatch = []; + if (strlen($path) >= 3 && preg_match('#^([A-Z]:)[/\\\]#i', substr($path, 0, 3), $driveMatch)) { + $this->root = $driveMatch[1]; + $path = substr($path, 2); + } + + // normalize \ to / + if (substr($path, 0, 1) === '\\') { + $path = str_replace('\\', '/', $path); + } + + if (substr($path, 0, 1) === '/') { + $this->root = trim($this->root, '/\\') . '/'; + $this->path = trim($path, '/'); + $this->detectSegments($this->path); + return true; + } + + return false; + } + + /** + * Normalize a stream path + * + * @param string $path + * @return bool + */ + private function normalizeStream(string $path): bool + { + $schemeMatch = []; + if (strlen($path) > 4 && preg_match('#^([A-Z]+://).#i', $path, $schemeMatch)) { + $this->root = $schemeMatch[1]; + $this->path = substr($path, strlen($this->root)); + $this->detectSegments($this->path); + return true; + } + return false; + } + + /** + * Detect all path segments + * + * @param string $path + */ + private function detectSegments(string $path) + { + $segments = empty($path) ? [] : explode('/', trim($path, '/')); + $segments = array_filter($segments); + $this->segments = []; + + foreach ($segments as $segment) { + if ($segment === '.') { + continue; + } + if ($segment === '..') { + array_pop($this->segments); + continue; + } + $this->segments[] = $segment; + } + + $this->depth = count($this->segments); + } + + /** + * Path getter + * + * @return string + */ + public function getPath(): string + { + return $this->raw; + } + + /** + * Root getter + * + * @return string + */ + public function getRoot(): string + { + return $this->root; + } + + /** + * Depth getter + * + * @return int + */ + public function getDepth(): int + { + return $this->depth; + } + + /** + * Segments getter + * + * @return array + */ + public function getSegments(): array + { + return $this->segments; + } + + /** + * Check if a path is child of a given parent path + * + * @param \SebastianFeldmann\Camino\Path\Directory $parent + * @return bool + */ + public function isChildOf(Directory $parent): bool + { + if (!$this->isPossibleParent($parent)) { + return false; + } + + // check every path segment of the parent + foreach ($parent->getSegments() as $index => $name) { + if ($this->segments[$index] !== $name) { + return false; + } + } + return true; + } + + /** + * Returns the relative path from a parent directory to this one + * + * @param \SebastianFeldmann\Camino\Path\Directory $parent + * @return string + */ + public function getRelativePathFrom(Directory $parent): string + { + if (!$this->isChildOf($parent)) { + throw new RuntimeException($this->getPath() . ' is not a child of ' . $parent->getPath()); + } + return implode('/', array_slice($this->segments, $parent->getDepth())); + } + + /** + * Check if a Directory possibly be a parent directory + * + * @param \SebastianFeldmann\Camino\Path\Directory $parent + * @return bool + */ + protected function isPossibleParent(Directory $parent): bool + { + + // if the root is different it can't be a subdirectory + if (!$this->hasSameRootAs($parent)) { + return false; + } + // if the parent has a deeper nesting level it can't be a parent + if ($parent->getDepth() > $this->getDepth()) { + return false; + } + return true; + } + + /** + * Check if a given path has the same root + * + * @param \SebastianFeldmann\Camino\Path $path + * @return bool + */ + protected function hasSameRootAs(Path $path): bool + { + return $this->root === $path->getRoot(); + } + + /** + * To string conversion method + * + * @return string + */ + public function __toString(): string + { + return $this->raw; + } +} diff --git a/lib/sebastianfeldmann/camino/src/Path/Directory.php b/lib/sebastianfeldmann/camino/src/Path/Directory.php new file mode 100644 index 0000000000..2b5c35eaad --- /dev/null +++ b/lib/sebastianfeldmann/camino/src/Path/Directory.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Camino\Path; + +use RuntimeException; + +/** + * Class Directory + * + * @package SebastianFeldmann\Camino + */ +final class Directory extends Base +{ + /** + * Checks if the directory is a sub directory of a given directory + * + * @param \SebastianFeldmann\Camino\Path\Directory $parent + * @return bool + */ + public function isSubDirectoryOf(Directory $parent): bool + { + return $this->isChildOf($parent); + } + + /** + * Factory method to create directories that actually exist + * + * @param string $path + * @return \SebastianFeldmann\Camino\Path\Directory + */ + public static function create(string $path): Directory + { + $realPath = realpath($path); + if (empty($realPath) || is_file($realPath)) { + throw new RuntimeException('directory does not exist: ' . $path); + } + return new self($realPath); + } +} diff --git a/lib/sebastianfeldmann/camino/src/Path/File.php b/lib/sebastianfeldmann/camino/src/Path/File.php new file mode 100644 index 0000000000..8a3c32d992 --- /dev/null +++ b/lib/sebastianfeldmann/camino/src/Path/File.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Camino\Path; + +use RuntimeException; + +/** + * Class File + * + * @package SebastianFeldmann\Camino + */ +final class File extends Base +{ + /** + * Returns a directory instance of its location + * + * @return \SebastianFeldmann\Camino\Path\Directory + */ + public function getDirectory(): Directory + { + return new Directory(dirname($this->raw)); + } + + /** + * Is the file located in a given directory + * + * @param \SebastianFeldmann\Camino\Path\Directory $directory + * @return bool + */ + public function isInDirectory(Directory $directory): bool + { + return $this->isChildOf($directory); + } + + /** + * Factory method to create files that actually exist + * + * @param string $path + * @return \SebastianFeldmann\Camino\Path\File + */ + public static function create(string $path): File + { + $realPath = realpath($path); + if (empty($realPath) || is_dir($realPath)) { + throw new RuntimeException('file does not exist: ' . $path); + } + return new self($realPath); + } +} diff --git a/lib/sebastianfeldmann/cli/.github/FUNDING.yml b/lib/sebastianfeldmann/cli/.github/FUNDING.yml new file mode 100644 index 0000000000..c67f279580 --- /dev/null +++ b/lib/sebastianfeldmann/cli/.github/FUNDING.yml @@ -0,0 +1 @@ +github: sebastianfeldmann diff --git a/lib/sebastianfeldmann/cli/.github/workflows/integration.yml b/lib/sebastianfeldmann/cli/.github/workflows/integration.yml new file mode 100644 index 0000000000..dba7a61cdc --- /dev/null +++ b/lib/sebastianfeldmann/cli/.github/workflows/integration.yml @@ -0,0 +1,44 @@ +name: "CI-Build" + +on: [push, pull_request] + +jobs: + build: + runs-on: ${{ matrix.operating-system }} + strategy: + max-parallel: 3 + matrix: + operating-system: [ubuntu-latest] + php-versions: ['7.3', '7.4', '8.0'] + steps: + - uses: actions/checkout@master + + - name: Install PHP + uses: shivammathur/setup-php@master + with: + php-version: ${{ matrix.php-versions }} + extensions: xdebug + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Install phive + run: wget -O phive.phar https://phar.io/releases/phive.phar + + - name: Make phive executable + run: chmod +x ./phive.phar + + - name: Install tooling + run: ./phive.phar --no-progress --home ./.phive install --trust-gpg-keys 4AA394086372C20A,31C7E470E2138192,8E730BA25823D8B5,CF1A108D0E7AE720,A978220305CD5C32,51C67305FFC2E5C0 --force-accept-unsigned + + - name: Execute unit tests + run: tools/phpunit + + - name: Check coding style + run: tools/phpcs --standard=psr12 src tests + + - name: Static code analysis + run: tools/phpstan analyse -c phpstan-${{ matrix.php-versions }}.neon diff --git a/lib/sebastianfeldmann/cli/LICENSE b/lib/sebastianfeldmann/cli/LICENSE new file mode 100644 index 0000000000..60f33c9184 --- /dev/null +++ b/lib/sebastianfeldmann/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Sebastian Feldmann + +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/lib/sebastianfeldmann/cli/composer.json b/lib/sebastianfeldmann/cli/composer.json new file mode 100644 index 0000000000..ee00bc0dfa --- /dev/null +++ b/lib/sebastianfeldmann/cli/composer.json @@ -0,0 +1,44 @@ +{ + "name": "sebastianfeldmann/cli", + "description": "PHP cli helper classes", + "type": "library", + "keywords": ["cli"], + "homepage": "https://github.com/sebastianfeldmann/cli", + "license": "MIT", + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/cli/issues" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "symfony/process": "^4.3 | ^5.0" + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Cli\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SebastianFeldmann\\Cli\\": "tests/cli/" + } + }, + "scripts": { + "post-install-cmd": "tools/phive install", + "test": "tools/phpunit", + "analyse": "tools/phpstan analyse", + "style": "tools/phpcs --standard=psr12 src tests" + }, + "extra": { + "branch-alias": { + "dev-master": "3.4.x-dev" + } + } +} diff --git a/lib/sebastianfeldmann/cli/phive.xml b/lib/sebastianfeldmann/cli/phive.xml new file mode 100644 index 0000000000..3c7194e15b --- /dev/null +++ b/lib/sebastianfeldmann/cli/phive.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/sebastianfeldmann/cli/phpstan.neon b/lib/sebastianfeldmann/cli/phpstan.neon new file mode 100644 index 0000000000..f4476cb254 --- /dev/null +++ b/lib/sebastianfeldmann/cli/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 5 + paths: + - %currentWorkingDirectory%/src/ diff --git a/lib/sebastianfeldmann/cli/src/Command.php b/lib/sebastianfeldmann/cli/src/Command.php new file mode 100644 index 0000000000..81b34de972 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Command.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli; + +/** + * Class Command + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +interface Command +{ + /** + * Get the cli command. + * + * @return string + */ + public function getCommand(): string; + + /** + * Returns a list of exit codes that are valid. + * + * @return array + */ + public function getAcceptableExitCodes(): array; + + /** + * Convert command to string + * + * @return string + */ + public function __toString(): string; +} diff --git a/lib/sebastianfeldmann/cli/src/Command/Executable.php b/lib/sebastianfeldmann/cli/src/Command/Executable.php new file mode 100644 index 0000000000..beb0024d8a --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Command/Executable.php @@ -0,0 +1,224 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Command; + +use SebastianFeldmann\Cli\Command; +use SebastianFeldmann\Cli\Util; + +/** + * Class Executable + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +class Executable implements Command +{ + /** + * Command name + * + * @var string + */ + private $cmd; + + /** + * Display stdErr + * + * @var boolean + */ + private $isSilent = false; + + /** + * Command options + * + * @var string[] + */ + private $options = []; + + /** + * List of variables to define + * + * @var string[] + */ + private $vars = []; + + /** + * List of acceptable exit codes. + * + * @var array + */ + private $acceptableExitCodes = []; + + /** + * Constructor. + * + * @param string $cmd + * @param int[] $exitCodes + */ + public function __construct(string $cmd, array $exitCodes = [0]) + { + $this->cmd = $cmd; + $this->acceptableExitCodes = $exitCodes; + } + + /** + * Returns the string to execute on the command line. + * + * @return string + */ + public function getCommand(): string + { + $cmd = $this->getVars() . sprintf('"%s"', $this->cmd) + . (count($this->options) ? ' ' . implode(' ', $this->options) : '') + . ($this->isSilent ? ' 2> /dev/null' : ''); + + return Util::escapeSpacesIfOnWindows($cmd); + } + + /** + * Returns a list of exit codes that are valid. + * + * @return int[] + */ + public function getAcceptableExitCodes(): array + { + return $this->acceptableExitCodes; + } + + /** + * Silence the 'Cmd' by redirecting its stdErr output to /dev/null. + * The silence feature is disabled for Windows systems. + * + * @param bool $bool + * @return \SebastianFeldmann\Cli\Command\Executable + */ + public function silence($bool = true): Executable + { + $this->isSilent = $bool && !defined('PHP_WINDOWS_VERSION_BUILD'); + return $this; + } + + /** + * Add option to list. + * + * @param string $option + * @param mixed $value + * @param string $glue + * @return \SebastianFeldmann\Cli\Command\Executable + */ + public function addOption(string $option, $value = null, string $glue = '='): Executable + { + if ($value !== null) { + // force space for multiple arguments e.g. --option 'foo' 'bar' + if (is_array($value)) { + $glue = ' '; + } + $value = $glue . $this->escapeArgument($value); + } else { + $value = ''; + } + $this->options[] = $option . $value; + + return $this; + } + + /** + * Add a var definition to a command + * + * @param string $name + * @param string $value + * @return $this + */ + public function addVar(string $name, string $value): Executable + { + $this->vars[$name] = $value; + + return $this; + } + + /** + * Return variable definition string e.g. "MYFOO='sometext' MYBAR='nothing' " + * + * @return string + */ + protected function getVars(): string + { + $varStrings = []; + + foreach ($this->vars as $name => $value) { + $varStrings[] = $name . '=' . escapeshellarg($value); + } + + return count($varStrings) ? implode(' ', $varStrings) . ' ' : ''; + } + + /** + * Adds an option to a command if it is not empty. + * + * @param string $option + * @param mixed $check + * @param bool $asValue + * @param string $glue + * @return \SebastianFeldmann\Cli\Command\Executable + */ + public function addOptionIfNotEmpty(string $option, $check, bool $asValue = true, string $glue = '='): Executable + { + if (!empty($check)) { + if ($asValue) { + $this->addOption($option, $check, $glue); + } else { + $this->addOption($option); + } + } + return $this; + } + + /** + * Add argument to list. + * + * @param mixed $argument + * @return \SebastianFeldmann\Cli\Command\Executable + */ + public function addArgument($argument): Executable + { + $this->options[] = $this->escapeArgument($argument); + return $this; + } + + /** + * Escape a shell argument. + * + * @param mixed $argument + * @return string + */ + protected function escapeArgument($argument): string + { + if (is_array($argument)) { + $argument = array_map('escapeshellarg', $argument); + $escaped = implode(' ', $argument); + } else { + $escaped = escapeshellarg($argument); + } + return $escaped; + } + + /** + * Returns the command to execute. + * + * @return string + */ + public function __toString(): string + { + return $this->getCommand(); + } +} diff --git a/lib/sebastianfeldmann/cli/src/Command/OutputFormatter.php b/lib/sebastianfeldmann/cli/src/Command/OutputFormatter.php new file mode 100644 index 0000000000..5cabbbabf1 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Command/OutputFormatter.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Command; + +/** + * Interface OutputFormatter + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +interface OutputFormatter +{ + /** + * Format the output. + * + * @param array $output + * @return iterable + */ + public function format(array $output): iterable; +} diff --git a/lib/sebastianfeldmann/cli/src/Command/Result.php b/lib/sebastianfeldmann/cli/src/Command/Result.php new file mode 100644 index 0000000000..133ed7edc1 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Command/Result.php @@ -0,0 +1,201 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Command; + +use SebastianFeldmann\Cli\Output\Util as OutputUtil; + +/** + * Class Result + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +class Result +{ + /** + * Command that got executed. + * + * @var string + */ + private $cmd; + + /** + * Result code. + * + * @var int + */ + private $code; + + /** + * List of valid exit codes. + * + * @var int[] + */ + private $validExitCodes; + + /** + * Output buffer. + * + * @var array + */ + private $buffer; + + /** + * StdOut. + * + * @var string + */ + private $stdOut; + + /** + * StdErr. + * + * @var string + */ + private $stdErr; + + /** + * Path where the output is redirected to. + * + * @var string + */ + private $redirectPath; + + /** + * Result constructor. + * + * @param string $cmd + * @param int $code + * @param string $stdOut + * @param string $stdErr + * @param string $redirectPath + * @param int[] $validExitCodes + */ + public function __construct( + string $cmd, + int $code, + string $stdOut = '', + string $stdErr = '', + string $redirectPath = '', + array $validExitCodes = [0] + ) { + $this->cmd = $cmd; + $this->code = $code; + $this->stdOut = $stdOut; + $this->stdErr = $stdErr; + $this->redirectPath = $redirectPath; + $this->validExitCodes = $validExitCodes; + } + + /** + * Cmd getter. + * + * @return string + */ + public function getCmd(): string + { + return $this->cmd; + } + + /** + * Code getter. + * + * @return int + */ + public function getCode(): int + { + return $this->code; + } + + /** + * Command executed successful. + */ + public function isSuccessful(): bool + { + return in_array($this->code, $this->validExitCodes); + } + + /** + * StdOutput getter. + * + * @return string + */ + public function getStdOut(): string + { + return $this->stdOut; + } + + /** + * StdError getter. + * + * @return string + */ + public function getStdErr(): string + { + return $this->stdErr; + } + + /** + * Is the output redirected to a file. + * + * @return bool + */ + public function isOutputRedirected(): bool + { + return !empty($this->redirectPath); + } + + /** + * Return path to the file where the output is redirected to. + * + * @return string + */ + public function getRedirectPath(): string + { + return $this->redirectPath; + } + + /** + * Return the output as array. + * + * @return array + */ + public function getStdOutAsArray(): array + { + if (null === $this->buffer) { + $this->buffer = $this->textToBuffer(); + } + return $this->buffer; + } + + /** + * Converts a string into an array. + * + * @return array + */ + private function textToBuffer(): array + { + return OutputUtil::trimEmptyLines(explode("\n", OutputUtil::normalizeLineEndings($this->stdOut))); + } + + /** + * Magic to string method. + * + * @return string + */ + public function __toString(): string + { + return $this->stdOut; + } +} diff --git a/lib/sebastianfeldmann/cli/src/Command/Runner.php b/lib/sebastianfeldmann/cli/src/Command/Runner.php new file mode 100644 index 0000000000..b20d3c1a22 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Command/Runner.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Command; + +use SebastianFeldmann\Cli\Command; +use SebastianFeldmann\Cli\Command\Runner\Result as RunnerResult; + +/** + * Interface Runner + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +interface Runner +{ + /** + * Execute a command. + * + * @param \SebastianFeldmann\Cli\Command $command + * @param \SebastianFeldmann\Cli\Command\OutputFormatter|null $formatter + * @return \SebastianFeldmann\Cli\Command\Runner\Result + */ + public function run(Command $command, ?OutputFormatter $formatter = null): RunnerResult; +} diff --git a/lib/sebastianfeldmann/cli/src/Command/Runner/Result.php b/lib/sebastianfeldmann/cli/src/Command/Runner/Result.php new file mode 100644 index 0000000000..9e8454b51d --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Command/Runner/Result.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Command\Runner; + +use SebastianFeldmann\Cli\Command\Result as CommandResult; + +/** + * Class Result + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +class Result +{ + /** + * Result of executed command. + * + * @var \SebastianFeldmann\Cli\Command\Result + */ + private $cmdResult; + + /** + * Formatted output of executed result. + * + * @var iterable + */ + private $formatted; + + /** + * Result constructor. + * + * @param \SebastianFeldmann\Cli\Command\Result $cmdResult + * @param iterable $formatted + */ + public function __construct(CommandResult $cmdResult, iterable $formatted = []) + { + $this->cmdResult = $cmdResult; + $this->formatted = $formatted; + } + + /** + * Get the raw command result. + * + * @return \SebastianFeldmann\Cli\Command\Result + */ + public function getCommandResult(): CommandResult + { + return $this->cmdResult; + } + + /** + * Return true if command execution was successful. + * + * @return bool + */ + public function isSuccessful(): bool + { + return $this->cmdResult->isSuccessful(); + } + + /** + * Return the command exit code. + * + * @return int + */ + public function getCode(): int + { + return $this->cmdResult->getCode(); + } + + /** + * Return the executed cli command. + * + * @return string + */ + public function getCmd(): string + { + return $this->cmdResult->getCmd(); + } + + /** + * Return commands output to stdOut. + * + * @return string + */ + public function getStdOut(): string + { + return $this->cmdResult->getStdOut(); + } + + /** + * Return commands error output to stdErr. + * + * @return string + */ + public function getStdErr(): string + { + return $this->cmdResult->getStdErr(); + } + + /** + * Is the output redirected to a file. + * + * @return bool + */ + public function isOutputRedirected(): bool + { + return $this->cmdResult->isOutputRedirected(); + } + + /** + * Return path to the file where the output is redirected to. + * + * @return string + */ + public function getRedirectPath(): string + { + return $this->cmdResult->getRedirectPath(); + } + + /** + * Return cmd output as array. + * + * @return array + */ + public function getBufferedOutput(): array + { + return $this->cmdResult->getStdOutAsArray(); + } + + /** + * Return formatted output. + * + * @return iterable + */ + public function getFormattedOutput(): iterable + { + return $this->formatted; + } +} diff --git a/lib/sebastianfeldmann/cli/src/Command/Runner/Simple.php b/lib/sebastianfeldmann/cli/src/Command/Runner/Simple.php new file mode 100644 index 0000000000..034c9bca5a --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Command/Runner/Simple.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Command\Runner; + +use RuntimeException; +use SebastianFeldmann\Cli\Command; +use SebastianFeldmann\Cli\Command\Runner; +use SebastianFeldmann\Cli\Command\OutputFormatter; +use SebastianFeldmann\Cli\Processor; + +/** + * Class Simple + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +class Simple implements Runner +{ + /** + * Class handling system calls. + * + * @var \SebastianFeldmann\Cli\Processor + */ + private $processor; + + /** + * Exec constructor. + * + * @param \SebastianFeldmann\Cli\Processor|null $processor + */ + public function __construct(?Processor $processor = null) + { + $this->processor = $processor !== null + ? $processor + : new Processor\ProcOpen(); + } + + /** + * Execute a cli command. + * + * @param \SebastianFeldmann\Cli\Command $command + * @param \SebastianFeldmann\Cli\Command\OutputFormatter|null $formatter + * @return \SebastianFeldmann\Cli\Command\Runner\Result + */ + public function run(Command $command, ?OutputFormatter $formatter = null): Result + { + $cmd = $this->processor->run($command->getCommand(), $command->getAcceptableExitCodes()); + + if (!$cmd->isSuccessful()) { + throw new RuntimeException( + 'Command failed:' . PHP_EOL + . ' exit-code: ' . $cmd->getCode() . PHP_EOL + . ' message: ' . $cmd->getStdErr() . PHP_EOL, + $cmd->getCode() + ); + } + + $formatted = $formatter !== null ? $formatter->format($cmd->getStdOutAsArray()) : []; + $result = new Result($cmd, $formatted); + + return $result; + } +} diff --git a/lib/sebastianfeldmann/cli/src/CommandLine.php b/lib/sebastianfeldmann/cli/src/CommandLine.php new file mode 100644 index 0000000000..2f330cd48e --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/CommandLine.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli; + +use RuntimeException; + +/** + * Class CommandLine + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +class CommandLine implements Command +{ + /** + * List of system commands to execute + * + * @var \SebastianFeldmann\Cli\Command[] + */ + private $commands = []; + + /** + * Redirect the output + * + * @var string + */ + private $redirectOutput; + + /** + * Output pipeline + * + * @var \SebastianFeldmann\Cli\Command[] + */ + private $pipeline = []; + + /** + * Should 'pipefail' be set? + * + * @var bool + */ + private $pipeFail = false; + + /** + * List of acceptable exit codes. + * + * @var array + */ + private $acceptedExitCodes = [0]; + + /** + * Set the list of accepted exit codes + * + * @param int[] $codes + * @return void + */ + public function acceptExitCodes(array $codes): void + { + $this->acceptedExitCodes = $codes; + } + + /** + * Redirect the stdOut. + * + * @param string $path + * @return void + */ + public function redirectOutputTo($path): void + { + $this->redirectOutput = $path; + } + + /** + * Should the output be redirected + * + * @return bool + */ + public function isOutputRedirected(): bool + { + return !empty($this->redirectOutput); + } + + /** + * Redirect getter. + * + * @return string + */ + public function getRedirectPath(): string + { + return $this->redirectOutput; + } + + /** + * Pipe the command into given command + * + * @param \SebastianFeldmann\Cli\Command $cmd + * @return void + */ + public function pipeOutputTo(Command $cmd): void + { + if (!$this->canPipe()) { + throw new RuntimeException('Can\'t pipe output'); + } + $this->pipeline[] = $cmd; + } + + /** + * Get the 'pipefail' option command snippet + * + * @return string + */ + public function getPipeFail(): string + { + return ($this->isPiped() && $this->pipeFail) ? 'set -o pipefail; ' : ''; + } + + /** + * Can the pipe '|' operator be used + * + * @return bool + */ + public function canPipe(): bool + { + return !defined('PHP_WINDOWS_VERSION_BUILD'); + } + + /** + * Is there a command pipeline + * + * @return bool + */ + public function isPiped(): bool + { + return !empty($this->pipeline); + } + + /** + * Should the pipefail option be set + * + * @param bool $pipeFail + */ + public function pipeFail(bool $pipeFail) + { + $this->pipeFail = $pipeFail; + } + + /** + * Return command pipeline + * + * @return string + */ + public function getPipeline(): string + { + return $this->isPiped() ? ' | ' . implode(' | ', $this->pipeline) : ''; + } + + /** + * Adds a cli command to list of commands to execute + * + * @param \SebastianFeldmann\Cli\Command $cmd + * @return void + */ + public function addCommand(Command $cmd): void + { + $this->commands[] = $cmd; + } + + /** + * Generates the system command + * + * @return string + */ + public function getCommand(): string + { + $amount = count($this->commands); + if ($amount < 1) { + throw new RuntimeException('no command to execute'); + } + $cmd = $this->getPipeFail() + . ($amount > 1 ? '(' . implode(' && ', $this->commands) . ')' : $this->commands[0]) + . $this->getPipeline() + . (!empty($this->redirectOutput) ? ' > ' . $this->redirectOutput : ''); + + return $cmd; + } + + /** + * Returns a list of exit codes that are valid + * + * @return array + */ + public function getAcceptableExitCodes(): array + { + return $this->acceptedExitCodes; + } + + /** + * Returns the command to execute + * + * @return string + */ + public function __toString(): string + { + return $this->getCommand(); + } +} diff --git a/lib/sebastianfeldmann/cli/src/Output/Util.php b/lib/sebastianfeldmann/cli/src/Output/Util.php new file mode 100644 index 0000000000..6db76f0096 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Output/Util.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Output; + +/** + * Class Util + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 2.1.0 + */ +class Util +{ + /** + * Remove empty entries at the end of an array. + * + * @param array $lines + * @return array + */ + public static function trimEmptyLines(array $lines): array + { + for ($last = count($lines) - 1; $last > -1; $last--) { + if (!empty($lines[$last])) { + return $lines; + } + unset($lines[$last]); + } + return $lines; + } + + /** + * Replaces all 'known' line endings with unix \n line endings + * + * @param string $text + * @return string + */ + public static function normalizeLineEndings(string $text): string + { + $mod = preg_match('/[\p{Cyrillic}]/u', $text) ? 'u' : ''; + return preg_replace('~(*BSR_UNICODE)\R~' . $mod, "\n", $text); + } +} diff --git a/lib/sebastianfeldmann/cli/src/Processor.php b/lib/sebastianfeldmann/cli/src/Processor.php new file mode 100644 index 0000000000..e8b4c90c35 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Processor.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli; + +use SebastianFeldmann\Cli\Command\Result; + +/** + * Interface Processor + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +interface Processor +{ + /** + * Execute a system call. + * + * @param string $cmd + * @param int[] $acceptableExitCodes + * @return \SebastianFeldmann\Cli\Command\Result + */ + public function run(string $cmd, array $acceptableExitCodes = [0]): Result; +} diff --git a/lib/sebastianfeldmann/cli/src/Processor/ProcOpen.php b/lib/sebastianfeldmann/cli/src/Processor/ProcOpen.php new file mode 100644 index 0000000000..ca5db1f0c1 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Processor/ProcOpen.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Processor; + +use RuntimeException; +use SebastianFeldmann\Cli\Command\Result; +use SebastianFeldmann\Cli\Processor; + +/** + * Class ProcOpen + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 0.9.0 + */ +class ProcOpen implements Processor +{ + /** + * Execute the command + * + * @param string $cmd + * @param int[] $acceptableExitCodes + * @return \SebastianFeldmann\Cli\Command\Result + */ + public function run(string $cmd, array $acceptableExitCodes = [0]): Result + { + $old = error_reporting(0); + $descriptorSpec = [ + ['pipe', 'r'], + ['pipe', 'w'], + ['pipe', 'w'], + ]; + + $process = proc_open($cmd, $descriptorSpec, $pipes); + if (!is_resource($process)) { + throw new RuntimeException('can\'t execute \'proc_open\''); + } + + // Loop on process until it exits normally. + $stdOut = ""; + $stdErr = ""; + do { + // Consume output streams while the process runs. The buffer will block process updates when full + $status = proc_get_status($process); + $stdOut .= stream_get_contents($pipes[1]); + $stdErr .= stream_get_contents($pipes[2]); + } while ($status['running']); + + // make sure all pipes are closed before calling proc_close + foreach ($pipes as $index => $pipe) { + fclose($pipe); + unset($pipes[$index]); + } + + proc_close($process); + error_reporting($old); + + return new Result($cmd, $status['exitcode'], $stdOut, $stdErr, '', $acceptableExitCodes); + } +} diff --git a/lib/sebastianfeldmann/cli/src/Processor/Symfony.php b/lib/sebastianfeldmann/cli/src/Processor/Symfony.php new file mode 100644 index 0000000000..73aacd3107 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Processor/Symfony.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Processor; + +use SebastianFeldmann\Cli\Command\Result; +use SebastianFeldmann\Cli\Processor; +use Symfony\Component\Process\Process; + +/** + * Class ProcOpen + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 3.2.2 + */ +class Symfony implements Processor +{ + /** + * Execute the command + * + * @param string $cmd + * @param int[] $acceptableExitCodes + * @return \SebastianFeldmann\Cli\Command\Result + */ + public function run(string $cmd, array $acceptableExitCodes = [0]): Result + { + // the else (:) variant is there to keep backwards compatibility with previous symfony versions + // and is only getting executed in those. This is the reason why the Process constructor is + // given a string instead of an array. The whole ternary can be removed if Symfony versions + // below 4.2 are not supported anymore. + $process = method_exists(Process::class, 'fromShellCommandline') + ? Process::fromShellCommandline($cmd) + : new Process($cmd); // @phpstan-ignore-line + + $process->setTimeout(null); + $process->run(); + return new Result( + $cmd, + $process->getExitCode(), + $process->getOutput(), + $process->getErrorOutput(), + '', + $acceptableExitCodes + ); + } +} diff --git a/lib/sebastianfeldmann/cli/src/Reader.php b/lib/sebastianfeldmann/cli/src/Reader.php new file mode 100644 index 0000000000..e4161c88d4 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Reader.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli; + +use Iterator; + +/** + * Interface Reader + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 3.3.0 + */ +interface Reader extends Iterator +{ + /** + * Get the current line. + * + * @return string + */ + public function current(): string; +} diff --git a/lib/sebastianfeldmann/cli/src/Reader/Abstraction.php b/lib/sebastianfeldmann/cli/src/Reader/Abstraction.php new file mode 100644 index 0000000000..175003b0b1 --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Reader/Abstraction.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Reader; + +use Iterator; +use SebastianFeldmann\Cli\Reader; + +/** + * Abstract Reader class + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 3.3.0 + */ +abstract class Abstraction implements Reader +{ + /** + * Internal iterator to handle foreach + * + * @var \Iterator + */ + private $iterator; + + /** + * Return the internal iterator + * + * @return \Iterator + */ + private function getIterator(): Iterator + { + return $this->iterator; + } + + /** + * Set the pointer to the next line + * + * @return void + */ + public function next(): void + { + $this->getIterator()->next(); + } + + /** + * Get the line number of the current line + * + * @return int + */ + public function key(): int + { + return $this->getIterator()->key(); + } + + /** + * Check whether the current line is valid + * + * @return bool + */ + public function valid(): bool + { + return $this->getIterator()->valid(); + } + + /** + * Recreate/rewind the iterator + * + * @return void + */ + public function rewind(): void + { + $this->iterator = $this->createIterator(); + } + + /** + * Get the current line + * + * @return string + */ + public function current(): string + { + return $this->getIterator()->current(); + } + + /** + * Create the internal iterator + * + * @return iterable + */ + abstract protected function createIterator(): iterable; +} diff --git a/lib/sebastianfeldmann/cli/src/Reader/StandardInput.php b/lib/sebastianfeldmann/cli/src/Reader/StandardInput.php new file mode 100644 index 0000000000..aa5d35cc6f --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Reader/StandardInput.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli\Reader; + +use Exception; + +/** + * StandardInput + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 3.3.0 + */ +class StandardInput extends Abstraction +{ + /** + * Standard Input stream handle + * + * @var resource + */ + private $handle; + + /** + * StandardInput constructor. + * + * @param resource $stdInHandle + */ + public function __construct($stdInHandle) + { + $this->handle = $stdInHandle; + } + + /** + * Create the generator + * + * @return iterable + * @throws \Exception + */ + protected function createIterator(): iterable + { + $read = [$this->handle]; + $write = []; + $except = []; + $result = stream_select($read, $write, $except, 0); + + if ($result === false) { + throw new Exception('stream_select failed'); + } + if ($result !== 0) { + while (!\feof($this->handle)) { + yield \fgets($this->handle); + } + } + } +} diff --git a/lib/sebastianfeldmann/cli/src/Util.php b/lib/sebastianfeldmann/cli/src/Util.php new file mode 100644 index 0000000000..ac69cfb9dd --- /dev/null +++ b/lib/sebastianfeldmann/cli/src/Util.php @@ -0,0 +1,326 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Cli; + +use RuntimeException; + +/** + * Interface Processor + * + * @package SebastianFeldmann\Cli + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/cli + * @since Class available since Release 1.0.4 + */ +abstract class Util +{ + /** + * List of console style codes. + * + * @var array + */ + private static $ansiCodes = [ + 'bold' => 1, + 'fg-black' => 30, + 'fg-red' => 31, + 'fg-green' => 32, + 'fg-yellow' => 33, + 'fg-cyan' => 36, + 'fg-white' => 37, + 'bg-red' => 41, + 'bg-green' => 42, + 'bg-yellow' => 43 + ]; + + /** + * Detect a given command's location. + * + * @param string $cmd The command to locate + * @param string $path Directory where the command should be + * @param array $optionalLocations Some fallback locations where to search for the command + * @return string Absolute path to detected command including command itself + * @throws \RuntimeException + */ + public static function detectCmdLocation(string $cmd, string $path = '', array $optionalLocations = []): string + { + $detectionSteps = [ + function ($cmd) use ($path) { + if (!empty($path)) { + return self::detectCmdLocationInPath($cmd, $path); + } + return ''; + }, + function ($cmd) { + return self::detectCmdLocationWithWhich($cmd); + }, + function ($cmd) { + $paths = explode(PATH_SEPARATOR, self::getEnvPath()); + return self::detectCmdLocationInPaths($cmd, $paths); + }, + function ($cmd) use ($optionalLocations) { + return self::detectCmdLocationInPaths($cmd, $optionalLocations); + } + ]; + + foreach ($detectionSteps as $step) { + $bin = $step($cmd); + if (!empty($bin)) { + return $bin; + } + } + + throw new RuntimeException(sprintf('\'%s\' was nowhere to be found please specify the correct path', $cmd)); + } + + /** + * Detect a command in a given path. + * + * @param string $cmd + * @param string $path + * @return string + * @throws \RuntimeException + */ + public static function detectCmdLocationInPath(string $cmd, string $path): string + { + $command = $path . DIRECTORY_SEPARATOR . $cmd; + $bin = self::getExecutable($command); + if (empty($bin)) { + throw new RuntimeException(sprintf('wrong path specified for \'%s\': %s', $cmd, $path)); + } + return $bin; + } + + /** + * Detect command location using which cli command. + * + * @param string $cmd + * @return string + */ + public static function detectCmdLocationWithWhich($cmd): string + { + $bin = ''; + // on nx systems use 'which' command. + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + $command = trim(`which $cmd`); + $bin = self::getExecutable($command); + } + return $bin; + } + + /** + * Check path list for executable command. + * + * @param string $cmd + * @param array $paths + * @return string + */ + public static function detectCmdLocationInPaths($cmd, array $paths): string + { + foreach ($paths as $path) { + $command = $path . DIRECTORY_SEPARATOR . $cmd; + $bin = self::getExecutable($command); + if (null !== $bin) { + return $bin; + } + } + return ''; + } + + /** + * Return local $PATH variable. + * + * @return string + * @throws \RuntimeException + */ + public static function getEnvPath(): string + { + // check for unix and windows case $_SERVER index + foreach (['PATH', 'Path', 'path'] as $index) { + if (isset($_SERVER[$index])) { + return $_SERVER[$index]; + } + } + throw new RuntimeException('cant find local PATH variable'); + } + + /** + * Returns the executable command if the command is executable, empty string otherwise. + * Search for $command.exe on Windows systems. + * + * @param string $command + * @return string + */ + public static function getExecutable($command): string + { + if (is_executable($command)) { + return $command; + } + // on windows check the .exe suffix + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $command .= '.exe'; + if (is_executable($command)) { + return $command; + } + } + return ''; + } + + /** + * Is given path absolute. + * + * @param string $path + * @return bool + */ + public static function isAbsolutePath($path): bool + { + // path already absolute? + if ($path[0] === '/') { + return true; + } + + // Matches the following on Windows: + // - \\NetworkComputer\Path + // - \\.\D: + // - \\.\c: + // - C:\Windows + // - C:\windows + // - C:/windows + // - c:/windows + if (defined('PHP_WINDOWS_VERSION_BUILD') && self::isAbsoluteWindowsPath($path)) { + return true; + } + + // Stream + if (strpos($path, '://') !== false) { + return true; + } + + return false; + } + + /** + * Is given path an absolute windows path. + * + * @param string $path + * @return bool + */ + public static function isAbsoluteWindowsPath($path): bool + { + return ($path[0] === '\\' || (strlen($path) >= 3 && preg_match('#^[A-Z]\:[/\\\]#i', substr($path, 0, 3)))); + } + + /** + * Converts a path to an absolute one if necessary relative to a given base path. + * + * @param string $path + * @param string $base + * @param bool $useIncludePath + * @return string + */ + public static function toAbsolutePath(string $path, string $base, bool $useIncludePath = false): string + { + if (self::isAbsolutePath($path)) { + return $path; + } + + $file = $base . DIRECTORY_SEPARATOR . $path; + + if ($useIncludePath && !file_exists($file)) { + $includePathFile = stream_resolve_include_path($path); + if ($includePathFile) { + $file = $includePathFile; + } + } + return $file; + } + + /** + * Formats a buffer with a specified ANSI color sequence if colors are enabled. + * + * @author Sebastian Bergmann + * @param string $color + * @param string $buffer + * @return string + */ + public static function formatWithColor(string $color, string $buffer): string + { + $codes = array_map('trim', explode(',', $color)); + $lines = explode("\n", $buffer); + $padding = max(array_map('strlen', $lines)); + + $styles = []; + foreach ($codes as $code) { + $styles[] = self::$ansiCodes[$code]; + } + $style = sprintf("\x1b[%sm", implode(';', $styles)); + + $styledLines = []; + foreach ($lines as $line) { + $styledLines[] = strlen($line) ? $style . str_pad($line, $padding) . "\x1b[0m" : ''; + } + + return implode(PHP_EOL, $styledLines); + } + + /** + * Fills up a text buffer with '*' to consume by default 72 chars. + * + * @param string $buffer + * @param int $length + * @return string + */ + public static function formatWithAsterisk(string $buffer, int $length = 72): string + { + return $buffer . str_repeat('*', $length - strlen($buffer)) . PHP_EOL; + } + + /** + * Can command pipe operator be used. + * + * @return bool + */ + public static function canPipe(): bool + { + return !defined('PHP_WINDOWS_VERSION_BUILD'); + } + + /** + * Removes a directory that is not empty. + * + * @param string $dir + */ + public static function removeDir(string $dir) + { + foreach (scandir($dir) as $file) { + if ('.' === $file || '..' === $file) { + continue; + } + if (is_dir($dir . '/' . $file)) { + self::removeDir($dir . '/' . $file); + } else { + unlink($dir . '/' . $file); + } + } + rmdir($dir); + } + + /** + * Wraps windows command with a double quote to escape spaces + * i.e: `E:/Program Files/tar.exe -zcf ...` escaped to `"E:/Program Files/tar.exe -zcf ..."` + * @param string $cmd + * @return string + */ + public static function escapeSpacesIfOnWindows(string $cmd): string + { + return defined('PHP_WINDOWS_VERSION_BUILD') ? sprintf('"%s"', $cmd) : $cmd; + } +} diff --git a/lib/sebastianfeldmann/git/LICENSE b/lib/sebastianfeldmann/git/LICENSE new file mode 100644 index 0000000000..60f33c9184 --- /dev/null +++ b/lib/sebastianfeldmann/git/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Sebastian Feldmann + +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/lib/sebastianfeldmann/git/composer.json b/lib/sebastianfeldmann/git/composer.json new file mode 100644 index 0000000000..6d65c6d997 --- /dev/null +++ b/lib/sebastianfeldmann/git/composer.json @@ -0,0 +1,48 @@ +{ + "name": "sebastianfeldmann/git", + "description": "PHP git wrapper", + "type": "library", + "keywords": ["git"], + "homepage": "https://github.com/sebastianfeldmann/git", + "license": "MIT", + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/git/issues" + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Git\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SebastianFeldmann\\Git\\": "tests/git/" + } + }, + "require": { + "php": ">=8.0", + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "sebastianfeldmann/cli": "^3.0" + }, + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "scripts": { + "test": "tools/phpunit", + "static": "tools/phpstan analyse", + "style": "tools/phpcs --standard=psr12 src tests", + "style-fix": "tools/phpcbf --standard=psr12 src tests" + }, + "require-dev": { + "mikey179/vfsstream": "^1.6" + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Add/AddFiles.php b/lib/sebastianfeldmann/git/src/Command/Add/AddFiles.php new file mode 100644 index 0000000000..bdf86f2363 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Add/AddFiles.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Add; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class AddFiles + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.7.0 + */ +class AddFiles extends Base +{ + /** + * Dry run. + * + * @var string + */ + private string $dryRun = ''; + + /** + * Update. + * + * @var string + */ + private string $update = ''; + + /** + * All. + * + * @var string + */ + private string $all = ''; + + /** + * No all. + * + * @var string + */ + private string $noAll = ''; + + /** + * Intent to add. + * + * @var string + */ + private string $intentToAdd = ''; + + /** + * Files to add content from to the index. + * + * @var string[] + */ + private array $files = []; + + /** + * Set dry run. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Add\AddFiles + */ + public function dryRun(bool $bool = true): AddFiles + { + $this->dryRun = $this->useOption('--dry-run', $bool); + return $this; + } + + /** + * Update the index just where it already has an entry matching . + * + * This removes as well as modifies index entries to match the working + * tree, but adds no new files. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Add\AddFiles + */ + public function update(bool $bool = true): AddFiles + { + $this->update = $this->useOption('--update', $bool); + return $this; + } + + /** + * Update the index not only where the working tree has a file matching + * but also where the index already has an entry. + * + * This adds, modifies, and removes index entries to match the working tree. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Add\AddFiles + */ + public function all(bool $bool = true): AddFiles + { + $this->all = $this->useOption('--all', $bool); + return $this; + } + + /** + * Update the index by adding new files that are unknown to the index and + * files modified in the working tree, but ignore files that have been + * removed from the working tree. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Add\AddFiles + */ + public function noAll(bool $bool = true): AddFiles + { + $this->noAll = $this->useOption('--no-all', $bool); + return $this; + } + + /** + * Record only the fact that the path will be added later. + * + * An entry for the path is placed in the index with no content. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Add\AddFiles + */ + public function intentToAdd(bool $bool = true): AddFiles + { + $this->intentToAdd = $this->useOption('--intent-to-add', $bool); + return $this; + } + + /** + * Files to add content from to the index. + * + * Fileglobs (e.g. `*.c`) can be given to add all matching files. Also a + * leading directory name (e.g. `dir` to add `dir/file1` and `dir/file2`) + * can be given to update the index to match the current state of the + * directory as a whole (e.g. specifying `dir` will record not just a file + * `dir/file1` modified in the working tree, a file `dir/file2` added to the + * working tree, but also a file `dir/file3` removed from the working tree). + * + * @param array $files + * + * @return \SebastianFeldmann\Git\Command\Add\AddFiles + */ + public function files(array $files): AddFiles + { + $this->files = $files; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'add' + . $this->dryRun + . $this->update + . $this->all + . $this->noAll + . $this->intentToAdd + . ' -- ' + . implode(' ', array_map('escapeshellarg', $this->files)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Apply/ApplyPatch.php b/lib/sebastianfeldmann/git/src/Command/Apply/ApplyPatch.php new file mode 100644 index 0000000000..f8e09713c7 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Apply/ApplyPatch.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Apply; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class ApplyPatch + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.7.0 + */ +class ApplyPatch extends Base +{ + /** + * Patch files to apply. + * + * @var string[] + */ + private array $patchFiles = []; + + /** + * Action to take when encountering whitespace. + * + * @var string + */ + private string $whitespace = ' --whitespace=\'warn\''; + + /** + * Number of leading path components to remove from the diff paths. + * + * @var string + */ + private string $pathComponents = ' -p1'; + + /** + * Ignore changes in whitespace in context lines. + * + * @var string + */ + private string $ignoreSpaceChange = ''; + + /** + * Patch files to apply. + * + * @param string[] $patchFiles + * @return \SebastianFeldmann\Git\Command\Apply\ApplyPatch + */ + public function patches(array $patchFiles): ApplyPatch + { + $this->patchFiles = $patchFiles; + return $this; + } + + /** + * Set the action to take when encountering whitespace. + * + * @param string $action + * @return \SebastianFeldmann\Git\Command\Apply\ApplyPatch + */ + public function whitespace(string $action = 'warn'): ApplyPatch + { + $this->whitespace = ' --whitespace=' . escapeshellarg($action); + return $this; + } + + /** + * Set the number of leading path components to remove from the diff paths. + * + * @param int $pathComponents + * @return \SebastianFeldmann\Git\Command\Apply\ApplyPatch + */ + public function pathComponents(int $pathComponents = 1): ApplyPatch + { + $this->pathComponents = ' -p' . $pathComponents; + return $this; + } + + /** + * Ignore changes in whitespace in context lines if necessary. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Apply\ApplyPatch + */ + public function ignoreSpaceChange(bool $bool = true): ApplyPatch + { + $this->ignoreSpaceChange = $this->useOption('--ignore-space-change', $bool); + return $this; + } + + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'apply' + . $this->pathComponents + . $this->whitespace + . $this->ignoreSpaceChange + . ' ' + . implode(' ', array_map('escapeshellarg', $this->patchFiles)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Base.php b/lib/sebastianfeldmann/git/src/Command/Base.php new file mode 100644 index 0000000000..93151b0a15 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Base.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command; + +use SebastianFeldmann\Cli\Command; + +/** + * Class Base + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +abstract class Base implements Command +{ + /** + * Repository root directory. + * + * @var string + */ + protected string $repositoryRoot; + + /** + * Configuration parameters to pass along with the command. + * + * @var array + */ + private array $configParameters = []; + + /** + * Base constructor. + * + * @param string $root + */ + public function __construct(string $root = '') + { + $this->repositoryRoot = $root; + } + + /** + * Return cli command to execute. + * + * @return string + */ + public function getCommand(): string + { + return 'git' + . $this->getRootOption() + . $this->getConfigParameterOptions() + . ' ' + . $this->getGitCommand(); + } + + /** + * Return list of acceptable exit codes. + * + * @return array + */ + public function getAcceptableExitCodes(): array + { + return [0]; + } + + /** + * Adds a configuration parameter to pass to the command. + * + * This only modifies the current command object. It does not change other + * command objects, nor does it affect `~/.gitconfig` or `.git/config`. + * + * @param string $name Configuration parameter name in the same format as + * listed by git config (subkeys separated by dots). + * @param scalar|null $value The parameter value, or `null` to unset a + * previously set configuration parameter. + * @return self + */ + public function setConfigParameter(string $name, $value): self + { + if ($value === null) { + unset($this->configParameters[$name]); + return $this; + } + + if (is_bool($value)) { + $value = ($value === true) ? 'true' : 'false'; + } + + $this->configParameters[$name] = (string) $value; + + return $this; + } + + /** + * Do we need the -C option. + * + * @return string + */ + protected function getRootOption(): string + { + $option = ''; + // if root is set + if (!empty($this->repositoryRoot)) { + // and it's not the current working directory + if (getcwd() !== $this->repositoryRoot) { + $option = ' -C ' . escapeshellarg($this->repositoryRoot); + } + } + return $option; + } + + /** + * Returns a string of any config parameters set for use in the command. + * + * @return string + */ + protected function getConfigParameterOptions(): string + { + $options = ''; + foreach ($this->configParameters as $name => $value) { + $options .= ' -c ' . escapeshellarg($name . '=' . $value); + } + return $options; + } + + /** + * Should an option be used or not. + * + * @param string $option + * @param bool $switch + * @return string + */ + protected function useOption(string $option, bool $switch): string + { + return ($switch ? ' ' . $option : ''); + } + + /** + * Auto cast method. + * + * @return string + */ + public function __toString(): string + { + return $this->getCommand(); + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + abstract protected function getGitCommand(): string; +} diff --git a/lib/sebastianfeldmann/git/src/Command/Branch/ListRemote.php b/lib/sebastianfeldmann/git/src/Command/Branch/ListRemote.php new file mode 100644 index 0000000000..fe785899b3 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Branch/ListRemote.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Branch; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class ListRemote + * + * @package SebastianFeldmann\Git + * @author David Eckhaus + */ +class ListRemote extends Base +{ + /** + * Return the command to execute + * + * @return string + */ + protected function getGitCommand(): string + { + return 'branch -r'; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Checkout/RestoreWorkingTree.php b/lib/sebastianfeldmann/git/src/Command/Checkout/RestoreWorkingTree.php new file mode 100644 index 0000000000..e37f0179f5 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Checkout/RestoreWorkingTree.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Checkout; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class RestoreWorkingTree + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.7.0 + */ +class RestoreWorkingTree extends Base +{ + /** + * Files and directories to restore + * + * @var array + */ + private array $files = ['.']; + + /** + * Skip the checkout hooks? + * + * @var bool + */ + private bool $noMoreHooks = false; + + /** + * Do not trigger git hooks while restoring + * + * @param bool $bool + * @return $this + */ + public function skipHooks(bool $bool = true): RestoreWorkingTree + { + $this->noMoreHooks = $bool; + return $this; + } + + /** + * Limits the paths affected by the operation to those specified here + * + * @param array $files + * + * @return \SebastianFeldmann\Git\Command\Checkout\RestoreWorkingTree + */ + public function files(array $files): RestoreWorkingTree + { + $this->files = $files; + return $this; + } + + /** + * Return the command to execute + * + * @return string + */ + protected function getGitCommand(): string + { + return ($this->noMoreHooks ? '-c core.hooksPath=/dev/null ' : '') . 'checkout --quiet' + . ' -- ' + . implode(' ', array_map('escapeshellarg', $this->files)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/CloneCmd/CloneCmd.php b/lib/sebastianfeldmann/git/src/Command/CloneCmd/CloneCmd.php new file mode 100644 index 0000000000..b16be51d63 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/CloneCmd/CloneCmd.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace SebastianFeldmann\Git\Command\CloneCmd; + +use SebastianFeldmann\Git\Command\Base; +use SebastianFeldmann\Git\Url; + +/** + * Class CloneCmd + * + * @package SebastianFeldmann\Git + * @author Andreas Frömer + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.8.0 + */ +final class CloneCmd extends Base +{ + /** + * @var Url + */ + private Url $url; + + /** + * @var string + */ + private string $dir = ''; + + /** + * @var string + */ + private string $depth = ''; + + public function __construct(Url $url) + { + $this->url = $url; + parent::__construct(); + } + + /** + * Specify the directory to clone into + * + * @param string $dir + * @return $this + */ + public function dir(string $dir = ''): CloneCmd + { + $this->dir = $dir; + return $this; + } + + /** + * Limit the history to the number of commits + * + * @param int $depth + * @return $this + */ + public function depth(int $depth): CloneCmd + { + $this->depth = $this->useOption('--depth=' . $depth, true); + return $this; + } + + /** + * Returns the git command to execute the clone + * + * @return string + */ + protected function getGitCommand(): string + { + return 'clone' + . $this->depth + . ' ' + . escapeshellarg($this->url->getUrl()) + . $this->useOption(escapeshellarg($this->dir), !empty($this->dir)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Config/Get.php b/lib/sebastianfeldmann/git/src/Command/Config/Get.php new file mode 100644 index 0000000000..f97f2010cf --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Config/Get.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Config; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class Get + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.0.2 + */ +class Get extends Base +{ + /** + * The name of the configuration key to get + * + * @var string + */ + private string $name; + + /** + * The name of the configuration key to get. + * + * @param string $name + * @return \SebastianFeldmann\Git\Command\Config\Get + */ + public function name(string $name): Get + { + $this->name = $name; + + return $this; + } + + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'config --get ' . escapeshellarg($this->name); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Config/GetVar.php b/lib/sebastianfeldmann/git/src/Command/Config/GetVar.php new file mode 100644 index 0000000000..5b33097949 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Config/GetVar.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Config; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetVar + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.14.0 + */ +class GetVar extends Base +{ + /** + * The name of the configuration key to get + * + * @var string + */ + private string $name; + + /** + * Set the name of the var to get + * + * @param string $name + * @return \SebastianFeldmann\Git\Command\Config\GetVar + */ + public function name(string $name): GetVar + { + $this->name = $name; + + return $this; + } + + /** + * Return the `git var` command to execute + * + * @return string + */ + protected function getGitCommand(): string + { + return 'var ' . escapeshellarg($this->name); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Config/ListSettings.php b/lib/sebastianfeldmann/git/src/Command/Config/ListSettings.php new file mode 100644 index 0000000000..eb8d0bc2f7 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Config/ListSettings.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Config; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class ListSettings + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.0.8 + */ +class ListSettings extends Base +{ + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'config --list'; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Config/ListVars.php b/lib/sebastianfeldmann/git/src/Command/Config/ListVars.php new file mode 100644 index 0000000000..9528029b56 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Config/ListVars.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Config; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class ListVars + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.14.0 + */ +class ListVars extends Base +{ + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'var -l'; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Config/MapValues.php b/lib/sebastianfeldmann/git/src/Command/Config/MapValues.php new file mode 100644 index 0000000000..35c32bfda1 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Config/MapValues.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Config; + +use SebastianFeldmann\Cli\Command\OutputFormatter; + +/** + * Class MapSettings + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.0.8 + */ +class MapValues implements OutputFormatter +{ + /** + * Format the output + * + * @param array $output + * @return iterable + */ + public function format(array $output): iterable + { + $formatted = []; + foreach ($output as $row) { + $keyValue = explode('=', $row); + $formatted[trim($keyValue[0])] = trim(implode('=', array_slice($keyValue, 1))); + } + return $formatted; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Describe/GetCurrentTag.php b/lib/sebastianfeldmann/git/src/Command/Describe/GetCurrentTag.php new file mode 100644 index 0000000000..bab12a841a --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Describe/GetCurrentTag.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Describe; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetCurrentTag + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.0.8 + */ +class GetCurrentTag extends Base +{ + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'describe --tags'; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Describe/GetMostRecentTag.php b/lib/sebastianfeldmann/git/src/Command/Describe/GetMostRecentTag.php new file mode 100644 index 0000000000..050cd90458 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Describe/GetMostRecentTag.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Describe; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetCurrentTag + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.0.8 + */ +class GetMostRecentTag extends Base +{ + /** + * @var string + */ + private string $before = ''; + + /** + * Glob to define excluded tags e.g **-RC* to exclude release candidate tags + * @var string + */ + private string $exclude = ''; + + /** + * Sets the start point to search for a tag + * + * @param string $hash + * @return \SebastianFeldmann\Git\Command\Describe\GetMostRecentTag + */ + public function before(string $hash): GetMostRecentTag + { + $this->before = $hash; + + return $this; + } + + /** + * Glob of tags to ignore e.g. **-RC* to ignore release candidate tags like '1.0.0-RC3' + * + * @param string $glob + * @return \SebastianFeldmann\Git\Command\Describe\GetMostRecentTag + */ + public function ignore(string $glob): GetMostRecentTag + { + $this->exclude = $glob; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'describe --tags --abbrev=0' . $this->tagsToIgnore() . $this->startingPoint(); + } + + /** + * Return the --exclude='xxx' option + * + * @return string + */ + private function tagsToIgnore(): string + { + return empty($this->exclude) ? '' : ' --exclude=' . escapeshellarg($this->exclude); + } + + /** + * Return the starting point where to start the search for a tag + * + * @return string + */ + private function startingPoint(): string + { + return empty($this->before) ? '' : ' ' . $this->before . '^'; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Diff/ChangedFiles.php b/lib/sebastianfeldmann/git/src/Command/Diff/ChangedFiles.php new file mode 100644 index 0000000000..4f70d89889 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Diff/ChangedFiles.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Diff; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class ChangedFiles + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.15.0 + */ +class ChangedFiles extends Base +{ + /** + * @var string + */ + private string $mode = '..'; + + /** + * @var string + */ + private string $from; + + /** + * @var string + */ + private string $to; + + /** + * @var array + */ + private array $filter; + + /** + * @return \SebastianFeldmann\Git\Command\Diff\ChangedFiles + */ + public function tipToTip(): ChangedFiles + { + $this->mode = '..'; + return $this; + } + + /** + * @return \SebastianFeldmann\Git\Command\Diff\ChangedFiles + */ + public function mergeBase(): ChangedFiles + { + $this->mode = '...'; + return $this; + } + + /** + * @param string $from + * @return \SebastianFeldmann\Git\Command\Diff\ChangedFiles + */ + public function fromRevision(string $from): ChangedFiles + { + $this->from = $from; + return $this; + } + + /** + * @param string $to + * @return \SebastianFeldmann\Git\Command\Diff\ChangedFiles + */ + public function toRevision(string $to): ChangedFiles + { + $this->to = $to; + return $this; + } + + /** + * Set --diff-filter + * + * @param array $filter + * @return \SebastianFeldmann\Git\Command\Diff\ChangedFiles + */ + public function useFilter(array $filter): ChangedFiles + { + $this->filter = $filter; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'diff' + . ' --diff-algorithm=myers' + . ' --name-only' + . (!empty($this->filter) ? ' --diff-filter=' . implode('', $this->filter) : '') + . ' ' . $this->getVersionsToCompare(); + } + + /** + * Returns the commit range for the diff command + * + * @return string + */ + protected function getVersionsToCompare(): string + { + return escapeshellarg($this->from) . $this->mode . (empty($this->to) ? 'head' : escapeshellarg($this->to)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Diff/Compare.php b/lib/sebastianfeldmann/git/src/Command/Diff/Compare.php new file mode 100644 index 0000000000..312e5c7ed1 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Diff/Compare.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Diff; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class Between + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.2.0 + */ +class Compare extends Base +{ + /** + * Compare A/B command snippet. + * + * @var string + */ + protected string $compare = ''; + + /** + * Ignore line endings. + * + * @var string + */ + protected string $ignoreEOL = ''; + + /** + * Show statistics only. + * + * @var string + */ + protected string $stats = ''; + + /** + * Ignore all whitespaces. + * + * @var string + */ + private string $ignoreWhitespaces = ''; + + /** + * Ignore submodules. + * + * @var string + */ + private string $ignoreSubmodules = ''; + + /** + * Number of context lines before and after the diff + * + * @var string + */ + private string $unified = ''; + + /** + * View the changes staged for the next commit. + * + * @var string + */ + private string $staged = ''; + + /** + * Compare two given revisions. + * + * @param string $from + * @param string $to + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function revisions(string $from, string $to): Compare + { + $this->compare = escapeshellarg($from) . ' ' . escapeshellarg($to); + return $this; + } + + /** + * Compares the working tree or index to a given commit-ish + * + * @param string $to + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function to(string $to = 'HEAD'): Compare + { + $this->compare = escapeshellarg($to); + return $this; + } + + /** + * Compares the index to a given commit hash + * + * This method is a shortcut for calling {@see staged()} and {@see to()}. + * + * @param string $to + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function indexTo(string $to = 'HEAD'): Compare + { + return $this->staged()->to($to); + } + + /** + * View the changes staged for the next commit relative to the + * named with {@see to()}. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function staged(bool $bool = true): Compare + { + $this->stats = $this->useOption('--staged', $bool); + return $this; + } + + /** + * Generate diffs with $amount lines of context instead of the usual three + * + * @param int $amount + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function withContextLines(int $amount): Compare + { + $this->unified = $amount === 3 ? '' : ' --unified=' . $amount; + return $this; + } + + /** + * Set diff statistics option. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function statsOnly(bool $bool = true): Compare + { + $this->stats = $this->useOption('--numstat', $bool); + return $this; + } + + /** + * Set ignore spaces at end of line. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function ignoreWhitespacesAtEndOfLine(bool $bool = true): Compare + { + $this->ignoreEOL = $this->useOption('--ignore-space-at-eol', $bool); + return $this; + } + + /** + * Set ignore all whitespaces. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function ignoreWhitespaces(bool $bool = true): Compare + { + $this->ignoreWhitespaces = $this->useOption('-w', $bool); + return $this; + } + + /** + * Set ignore submodules. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Diff\Compare + */ + public function ignoreSubmodules(bool $bool = true): Compare + { + $this->ignoreSubmodules = $this->useOption('--ignore-submodules', $bool); + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'diff' + . ' --no-ext-diff' + . ' --diff-algorithm=myers' + . $this->unified + . $this->ignoreWhitespaces + . $this->ignoreSubmodules + . $this->ignoreEOL + . $this->stats + . $this->staged + . ' ' . $this->compare + . ' -- '; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Diff/Compare/FullDiffList.php b/lib/sebastianfeldmann/git/src/Command/Diff/Compare/FullDiffList.php new file mode 100644 index 0000000000..820af38fec --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Diff/Compare/FullDiffList.php @@ -0,0 +1,327 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Diff\Compare; + +use SebastianFeldmann\Cli\Command\OutputFormatter; +use SebastianFeldmann\Git\Diff\Change; +use SebastianFeldmann\Git\Diff\File; +use SebastianFeldmann\Git\Diff\Line; + +/** + * FullDiffList output formatter. + * + * Returns a list of SebastianFeldmann\Git\Diff\File objects. Each containing + * the list of changes that happened in that file. + * + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.2.0 + */ +class FullDiffList implements OutputFormatter +{ + /** + * Available line types of git diff output + */ + private const LINE_TYPE_START = 'Start'; + private const LINE_TYPE_HEADER = 'Header'; + private const LINE_TYPE_SIMILARITY = 'HeaderSimilarity'; + private const LINE_TYPE_OP = 'HeaderOp'; + private const LINE_TYPE_INDEX = 'HeaderIndex'; + private const LINE_TYPE_FORMAT = 'HeaderFormat'; + private const LINE_TYPE_POSITION = 'ChangePosition'; + private const LINE_TYPE_CODE = 'ChangeCode'; + + /** + * Search and parse strategy + * + * Define possible follow up lines for each line type to minimize search effort. + * + * @var array> + */ + private static array $lineTypesToCheck = [ + self::LINE_TYPE_START => [ + self::LINE_TYPE_HEADER + ], + self::LINE_TYPE_HEADER => [ + self::LINE_TYPE_SIMILARITY, + self::LINE_TYPE_OP, + self::LINE_TYPE_INDEX, + ], + self::LINE_TYPE_SIMILARITY => [ + self::LINE_TYPE_OP, + self::LINE_TYPE_INDEX, + ], + self::LINE_TYPE_OP => [ + self::LINE_TYPE_OP, + self::LINE_TYPE_INDEX + ], + self::LINE_TYPE_INDEX => [ + self::LINE_TYPE_FORMAT + ], + self::LINE_TYPE_FORMAT => [ + self::LINE_TYPE_FORMAT, + self::LINE_TYPE_POSITION + ], + self::LINE_TYPE_POSITION => [ + self::LINE_TYPE_CODE + ], + self::LINE_TYPE_CODE => [ + self::LINE_TYPE_HEADER, + self::LINE_TYPE_POSITION, + self::LINE_TYPE_CODE + ] + ]; + + /** + * Maps git diff output to file operations + * + * @var array + */ + private static array $opsMap = [ + 'old' => File::OP_MODIFIED, + 'new' => File::OP_CREATED, + 'deleted' => File::OP_DELETED, + 'rename' => File::OP_RENAMED, + 'copy' => File::OP_COPIED, + ]; + + /** + * List of diff File objects + * + * @var array<\SebastianFeldmann\Git\Diff\File> + */ + private array $files = []; + + /** + * The currently processed file + * + * @var \SebastianFeldmann\Git\Diff\File|null + */ + private ?File $currentFile = null; + + /** + * The file name of the currently processed file + * + * @var string|null + */ + private ?string $currentFileName = null; + + /** + * The change position of the currently processed file + * + * @var string|null + */ + private ?string $currentPosition = null; + + /** + * The operation of the currently processed file + * + * @var string|null + */ + private ?string $currentOperation = null; + + /** + * List of collected changes + * + * @var \SebastianFeldmann\Git\Diff\Change[] + */ + private array $currentChanges = []; + + /** + * Format the output + * + * @param array $output + * @return iterable<\SebastianFeldmann\Git\Diff\File> + */ + public function format(array $output): iterable + { + $previousLineType = self::LINE_TYPE_START; + // for each line of the output + for ($i = 0, $length = count($output); $i < $length; $i++) { + $line = $output[$i]; + // depending on the previous line type + // check for all possible following line types and handle it + foreach (self::$lineTypesToCheck[$previousLineType] as $typeToCheck) { + $call = 'is' . $typeToCheck . 'Line'; + // if the line type could be matched + if ($this->$call($line)) { + // remember the line type + $previousLineType = $typeToCheck; + break; + } + } + } + $this->appendCollectedFileAndChanges(); + + return $this->files; + } + + /** + * Is the given line a diff header line + * + * diff --git a/some/file b/some/file + * + * @param string $line + * @return bool + */ + private function isHeaderLine(string $line): bool + { + $matches = []; + if (preg_match('#^diff --git [abciwo]/(.*) [abciwo]/(.*)#', $line, $matches)) { + $this->appendCollectedFileAndChanges(); + $this->currentOperation = File::OP_MODIFIED; + $this->currentFileName = $matches[2]; + return true; + } + return false; + } + + /** + * Is the given line a diff header similarity line. + * + * similarity index 96% + * + * @param string $line + * @return bool + */ + private function isHeaderSimilarityLine(string $line): bool + { + $matches = []; + return (bool)preg_match('#^(similarity|dissimilarity) index [0-9]+%$#', $line, $matches); + } + + /** + * Is the given line a diff header operation line. + * + * new file mode 100644 + * delete file + * rename from some/file + * rename to some/other/file + * + * @param string $line + * @return bool + */ + private function isHeaderOpLine(string $line): bool + { + $matches = []; + if (preg_match('#^(old|new|deleted|rename|copy) (file mode|from|to) (.+)#', $line, $matches)) { + $this->currentOperation = self::$opsMap[$matches[1]]; + return true; + } + return false; + } + + /** + * Is the given line a diff header index line. + * + * index f7fc435..7b5bd26 100644 + * + * @param string $line + * @return bool + */ + private function isHeaderIndexLine(string $line): bool + { + $matches = []; + if (preg_match('#^index\s([a-z0-9]+)\.\.([a-z0-9]+)(.*)$#i', $line, $matches)) { + $this->currentFile = new File($this->currentFileName, $this->currentOperation); + return true; + } + return false; + } + + /** + * Is the given line a diff header format line. + * + * --- a/some/file + * +++ b/some/file + * + * @param string $line + * @return bool + */ + private function isHeaderFormatLine(string $line): bool + { + $matches = []; + return (bool)preg_match('#^[\\-\\+]{3} [abciwo]?/.*#', $line, $matches); + } + + /** + * Is the given line a diff change position line. + * + * @@ -4,3 +4,10 @@ some file hint + * + * @param string $line + * @return bool + */ + private function isChangePositionLine(string $line): bool + { + $matches = []; + if (preg_match('#^@@ (-\d+(?:,\d+)? \+\d+(?:,\d+)?) @@ ?(.*)$#', $line, $matches)) { + $this->currentPosition = $matches[1]; + $this->currentChanges[$this->currentPosition] = new Change($matches[1], $matches[2]); + return true; + } + return false; + } + + /** + * In our case we treat every line as code line if no other line type matched before. + * + * @param string $line + * @return bool + */ + private function isChangeCodeLine(string $line): bool + { + $line = $this->parseCodeLine($line); + if ($line === null) { + return false; + } + $this->currentChanges[$this->currentPosition]->addLine($line); + return true; + } + + /** + * Determines the line type and cleans up the line. + * + * @param string $line + * @return \SebastianFeldmann\Git\Diff\Line|null + */ + private function parseCodeLine(string $line): ?Line + { + if (strlen($line) == 0) { + return new Line(Line::EXISTED, ''); + } + + $firstChar = $line[0]; + if (!array_key_exists($firstChar, Line::$opsMap)) { + return null; + } + $cleanLine = rtrim(substr($line, 1)); + + return new Line(Line::$opsMap[$firstChar], $cleanLine); + } + + /** + * Add all collected changes to its file. + * + * @return void + */ + private function appendCollectedFileAndChanges(): void + { + if ($this->currentFile !== null) { + foreach ($this->currentChanges as $change) { + $this->currentFile->addChange($change); + } + $this->files[] = $this->currentFile; + } + $this->currentChanges = []; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles.php b/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles.php new file mode 100644 index 0000000000..16e70c788f --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\DiffIndex; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetStagedFiles + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class GetStagedFiles extends Base +{ + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'diff-index --diff-algorithm=myers --no-ext-diff --cached --name-status HEAD'; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles/FilterByStatus.php b/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles/FilterByStatus.php new file mode 100644 index 0000000000..c129d6e51d --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetStagedFiles/FilterByStatus.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\DiffIndex\GetStagedFiles; + +use SebastianFeldmann\Cli\Command\OutputFormatter; + +/** + * Class FilterByStatus + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class FilterByStatus implements OutputFormatter +{ + /** + * List of status to keep + * + * @var array + */ + private array $status; + + /** + * FilterByStatus constructor + * + * @param array $status + */ + public function __construct(array $status) + { + $this->status = $status; + } + + /** + * Format the output + * + * @param array $output + * @return iterable + */ + public function format(array $output): iterable + { + $formatted = []; + $pattern = sprintf('#^(?:%s)\t(.+)$#i', implode('|', $this->status)); + foreach ($output as $row) { + $matches = []; + if (preg_match($pattern, $row, $matches)) { + $formatted[] = $matches[1]; + } + } + return $formatted; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetUnstagedPatch.php b/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetUnstagedPatch.php new file mode 100644 index 0000000000..6353a19c02 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/DiffIndex/GetUnstagedPatch.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\DiffIndex; + +use SebastianFeldmann\Git\Command\Base; +use SebastianFeldmann\Git\Command\Status\WorkingTreeStatus; + +/** + * Class GetUnstagedPatch + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.7.0 + */ +class GetUnstagedPatch extends Base +{ + /** + * Tree object ID. + * + * @var string|null + */ + private ?string $treeId = null; + + /** + * Return list of acceptable exit codes. + * + * @return array + */ + public function getAcceptableExitCodes(): array + { + return [0, 1]; + } + + /** + * Set tree object ID. + * + * @param string|null $treeId + * + * @return \SebastianFeldmann\Git\Command\DiffIndex\GetUnstagedPatch + */ + public function tree(?string $treeId): GetUnstagedPatch + { + $this->treeId = $treeId; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'diff-index' + . ' --diff-algorithm=myers' + . ' --ignore-submodules' + . ' --binary' + . ' --exit-code' + . ' --no-color' + . ' --no-ext-diff' + . ($this->treeId ? ' ' . escapeshellarg($this->treeId) : '') + . ' -- '; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/DiffTree/ChangedFiles.php b/lib/sebastianfeldmann/git/src/Command/DiffTree/ChangedFiles.php new file mode 100644 index 0000000000..68b6dc6b3e --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/DiffTree/ChangedFiles.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\DiffTree; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class ChangedFiles + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 2.0.1 + */ +class ChangedFiles extends Base +{ + /** + * @var string + */ + private string $from; + + /** + * @var string + */ + private string $to; + + /** + * @var array + */ + private array $filter; + + /** + * @param string $from + * @return \SebastianFeldmann\Git\Command\DiffTree\ChangedFiles + */ + public function fromRevision(string $from): ChangedFiles + { + $this->from = $from; + return $this; + } + + /** + * @param string $to + * @return \SebastianFeldmann\Git\Command\DiffTree\ChangedFiles + */ + public function toRevision(string $to): ChangedFiles + { + $this->to = $to; + return $this; + } + + /** + * Set --diff-filter + * + * @param array $filter + * @return \SebastianFeldmann\Git\Command\DiffTree\ChangedFiles + */ + public function useFilter(array $filter): ChangedFiles + { + $this->filter = $filter; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'diff-tree' + . ' --diff-algorithm=myers' + . ' --no-ext-diff' + . ' --no-commit-id' + . ' --name-only' + . ' -r' + . (!empty($this->filter) ? ' --diff-filter=' . implode('', $this->filter) : '') + . ' ' . $this->getVersionsToCompare(); + } + + /** + * Returns the commit range for the diff command + * + * @return string + */ + protected function getVersionsToCompare(): string + { + return escapeshellarg($this->from) . (empty($this->to) ? '' : ' ' . escapeshellarg($this->to)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Fetch/Fetch.php b/lib/sebastianfeldmann/git/src/Command/Fetch/Fetch.php new file mode 100644 index 0000000000..886519886f --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Fetch/Fetch.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Fetch; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class Fetch + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.9.4 + */ +class Fetch extends Base +{ + /** + * Dry run. + * + * @var string + */ + private string $dryRun = ''; + + /** + * --all + * + * @var string + */ + private string $all = ''; + + /** + * --force + * + * @var string + */ + private string $force = ''; + + /** + * Remote to fetchBranch refs from + * + * @var string + */ + private $remote = ''; + + /** + * Branch name to fetchBranch + * + * @var string + */ + private $refSpec = ''; + + /** + * Set dry run. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Fetch\Fetch + */ + public function dryRun(bool $bool = true): Fetch + { + $this->dryRun = $this->useOption('--dry-run', $bool); + return $this; + } + + /** + * Fetch all remotes + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Fetch\Fetch + */ + public function all(bool $bool = true): Fetch + { + $this->all = $this->useOption('--all', $bool); + return $this; + } + + /** + * Force update + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Fetch\Fetch + */ + public function force(bool $bool = true): Fetch + { + $this->force = $this->useOption('--force', $bool); + return $this; + } + + /** + * Set the remote to fetchBranch from + * + * @param string $remote + * @return $this + */ + public function remote(string $remote): Fetch + { + $this->remote = $remote; + return $this; + } + + /** + * Set the branch to fetchBranch + * + * @param string $branch + * @return $this + */ + public function branch(string $branch): Fetch + { + $this->refSpec = $branch; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'fetch' + . $this->dryRun + . $this->all + . $this->force + . $this->getRepoAndRefSpec(); + } + + /** + * Returns the arguments for the fetchBranch command + * + * @return string + */ + private function getRepoAndRefSpec(): string + { + if (!empty($this->refSpec) && empty($this->remote)) { + $this->remote = 'origin'; + } + return $this->useOption($this->remote, !empty($this->remote)) + . $this->useOption($this->refSpec, !empty($this->refSpec)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Log/ChangedFiles.php b/lib/sebastianfeldmann/git/src/Command/Log/ChangedFiles.php new file mode 100644 index 0000000000..357977863f --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Log/ChangedFiles.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Log; + +/** + * Class ChangedFiles + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class ChangedFiles extends Log +{ + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'log --format=' . escapeshellarg('') + . ' --name-only' + . $this->diffFilter + . $this->author + . $this->merges + . $this->date + . $this->revSelection; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Log/Commits.php b/lib/sebastianfeldmann/git/src/Command/Log/Commits.php new file mode 100644 index 0000000000..1aa00803d4 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Log/Commits.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Log; + +/** + * Class Commits + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class Commits extends Log +{ + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'log --pretty=' . $this->escape('format:' . $this->format) + . $this->abbrev + . $this->author + . $this->merges + . $this->date + . $this->revSelection; + } + + /** + * This makes sure the % and ! signs within the format string will not be replaced on windows by 'escapeshellarg' + * + * @param string $arg + * @return string + */ + private function escape(string $arg): string + { + // this is a dirty hack to make it work under windows + return defined('PHP_WINDOWS_VERSION_MAJOR') ? '"' . $arg . '"' : escapeshellarg($arg); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Log/Commits/Jsonized.php b/lib/sebastianfeldmann/git/src/Command/Log/Commits/Jsonized.php new file mode 100644 index 0000000000..064c1c4acf --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Log/Commits/Jsonized.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Log\Commits; + +use SebastianFeldmann\Cli\Command\OutputFormatter; +use SebastianFeldmann\Git\Log\Commit; + +/** + * Class Jsonized + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class Jsonized implements OutputFormatter +{ + /** + * Git log format to use. + * + * @var string + */ + public const FORMAT = '{"hash": "%h", "names": "%d", "subject": "%s", "date": "%ci", "author": "%an"}'; + + /** + * Format the output. + * + * @param array $output + * @return iterable + * @throws \Exception + */ + public function format(array $output): iterable + { + $formatted = []; + foreach ($output as $row) { + $formatted[] = $this->createCommit($row); + } + return $formatted; + } + + /** + * Create a log commit object. + * + * @param string $row + * @return \SebastianFeldmann\Git\Log\Commit + * @throws \Exception + */ + private function createCommit(string $row): Commit + { + $std = json_decode($row); + $date = new \DateTimeImmutable($std->date); + $names = array_map('trim', explode(',', str_replace(['(', ')'], '', $std->names))); + + return new Commit($std->hash, $names, $std->subject, '', $date, $std->author); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Log/Commits/Xml.php b/lib/sebastianfeldmann/git/src/Command/Log/Commits/Xml.php new file mode 100644 index 0000000000..fef51dcabe --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Log/Commits/Xml.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Log\Commits; + +use SebastianFeldmann\Git\Log\Commit; + +/** + * Class Xml + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.2.0 + */ +class Xml +{ + /** + * XML commit format to parse the git log as xml + * + * @var string + */ + public const FORMAT = '%n' . + '%h%n' . + '%n' . + '%ci%n' . + '%n' . + '%n' . + '%n' . + ''; + + /** + * Parse log output into list of Commit objects + * + * @param string $output + * @return array<\SebastianFeldmann\Git\Log\Commit> + * @throws \Exception + */ + public static function parseLogOutput(string $output): array + { + $log = []; + $xml = '' . $output . ''; + + $parsedXML = \simplexml_load_string($xml, "SimpleXMLElement", LIBXML_NOERROR); + if (!$parsedXML) { + return $log; + } + + foreach ($parsedXML->commit as $commitXML) { + $nameRaw = str_replace(['(', ')'], '', trim((string) $commitXML->names)); + $names = empty($nameRaw) ? [] : array_map('trim', explode(',', $nameRaw)); + + $log[] = new Commit( + trim((string) $commitXML->hash), + $names, + trim((string) $commitXML->subject), + trim((string) $commitXML->body), + new \DateTimeImmutable(trim((string) $commitXML->date)), + trim((string) $commitXML->author) + ); + } + return $log; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Log/Log.php b/lib/sebastianfeldmann/git/src/Command/Log/Log.php new file mode 100644 index 0000000000..aba7826f0c --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Log/Log.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Log; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class Log + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +abstract class Log extends Base +{ + /** + * Pretty log format. + * --pretty + * + * @var string + */ + protected string $format = '%h -%d %s (%ci) <%an>'; + + /** + * Diff filter types + * --diff-filter + * + * @var string + */ + protected string $diffFilter = ''; + + /** + * Include or hide merge commits. + * --no-merges + * + * @var string + */ + protected string $merges = ' --no-merges'; + + + /** + * Set min and max date + * --before --after + * + * @var string + */ + protected string $date = ''; + + /** + * Shorten commit hashes. + * --abbrev-commit + * + * @var string + */ + protected string $abbrev = ' --abbrev-commit'; + + /** + * Can be revision or date query. + * 1.0.0.. + * 0.9.0..1.2.0 + * --after='2016-12-31' + * --after='2016-12-31' --before='2017-01-31' + * + * @var string + */ + protected string $revSelection = ''; + + /** + * Filter log by author. + * --author + * + * @var string + */ + protected string $author = ''; + + /** + * Define the pretty log format. + * + * @param string $format + * @return \SebastianFeldmann\Git\Command\Log\Log + */ + public function prettyFormat(string $format): Log + { + $this->format = $format; + return $this; + } + + /** + * Set the diff filter + * + * @param array $filter + * @return $this + */ + public function withDiffFilter(array $filter): Log + { + $this->diffFilter = empty($filter) ? '' : ' --diff-filter=' . implode('', $filter); + return $this; + } + + /** + * Define merge commit behaviour. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Log\Log + */ + public function withMerges(bool $bool = true): Log + { + $this->merges = ($bool ? '' : ' --no-merges'); + return $this; + } + + /** + * Define commit hash behaviour. + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Log\Log + */ + public function abbrevCommit(bool $bool = true): Log + { + $this->abbrev = ($bool ? ' --abbrev--commit' : ''); + return $this; + } + + /** + * Set revision range. + * + * REV..REV + * REV.. // meaning HASH..HEAD + * + * @param string $from + * @param string $to + * @return \SebastianFeldmann\Git\Command\Log\Log + */ + public function byRange(string $from, string $to = ''): Log + { + $this->revSelection = ' ' . escapeshellarg($from) . '..' + . (empty($to) ? '' : escapeshellarg($to)); + return $this; + } + + /** + * Set list of revisions to check + * + * @param string ...$revisions + * @return $this + */ + public function byRevisions(string ...$revisions): Log + { + $this->revSelection = implode(' ', $revisions); + return $this; + } + + /** + * Set author filter. + * + * @param string $author + * @return \SebastianFeldmann\Git\Command\Log\Log + */ + public function authoredBy(string $author): Log + { + $this->author = ' --author=' . escapeshellarg($author); + return $this; + } + + /** + * Set date range. + * + * @param string $from + * @param string $to + * @return \SebastianFeldmann\Git\Command\Log\Log + */ + public function byDate(string $from, string $to = ''): Log + { + $this->date = ' --after=' . escapeshellarg($from) + . (empty($to) ? '' : ' --before=' . escapeshellarg($to)); + return $this; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/LsTree/GetFiles.php b/lib/sebastianfeldmann/git/src/Command/LsTree/GetFiles.php new file mode 100644 index 0000000000..428a083113 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/LsTree/GetFiles.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\LsTree; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetFiles + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.4.0 + */ +class GetFiles extends Base +{ + /** + * Tree to check head by default + * @var string + */ + private string $tree = 'HEAD'; + + /** + * Path to check for files + * + * @var string + */ + private string $path = ''; + + /** + * Define the tree to search through + * + * @param string $tree + * @return \SebastianFeldmann\Git\Command\LsTree\GetFiles + */ + public function fromTree(string $tree): GetFiles + { + $this->tree = $tree; + return $this; + } + + /** + * Compares the index to a given commit hash + * + * @param string $path + * @return \SebastianFeldmann\Git\Command\LsTree\GetFiles + */ + public function inPath(string $path): GetFiles + { + $this->path = !empty($path) ? ' ' . $path : $path; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'ls-tree --name-only -r ' . $this->tree . $this->path; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/MergeBase/MergeBase.php b/lib/sebastianfeldmann/git/src/Command/MergeBase/MergeBase.php new file mode 100644 index 0000000000..f46c6f5825 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/MergeBase/MergeBase.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\MergeBase; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class MergeBase + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.15.1 + */ +class MergeBase extends Base +{ + /** + * Dry run. + * + * @var string + */ + private string $base = ''; + + /** + * Update. + * + * @var string + */ + private string $branch = 'HEAD'; + + /** + * Set dry run. + * + * @param string $branch * + * @return \SebastianFeldmann\Git\Command\MergeBase\MergeBase + */ + public function ofBranch(string $branch): MergeBase + { + $this->branch = $branch; + return $this; + } + + /** + * Set dry run. + * + * @param string $base * + * @return \SebastianFeldmann\Git\Command\MergeBase\MergeBase + */ + public function relativeTo(string $base): MergeBase + { + $this->base = $base; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'merge-base ' . escapeshellarg($this->base) . ' ' . escapeshellarg($this->branch); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Output/Exploded.php b/lib/sebastianfeldmann/git/src/Command/Output/Exploded.php new file mode 100644 index 0000000000..51b9a1daf8 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Output/Exploded.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Output; + +use SebastianFeldmann\Cli\Command\OutputFormatter; + +/** + * Output formatter that uses explode to return list of maps + * + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.10.0 + */ +class Exploded implements OutputFormatter +{ + /** + * @var string + */ + protected string $separator; + + /** + * @var array + */ + private array $names; + + /** + * @param string $separator + * @param array $names + */ + public function __construct(string $separator, array $names) + { + $this->separator = $separator; + $this->names = $names; + } + + /** + * Format the output + * + * @param array $output + * @return iterable> + */ + public function format(array $output): iterable + { + $logs = []; + foreach ($output as $line) { + $parts = explode($this->separator, $line); + $log = []; + foreach ($parts as $index => $value) { + $log[$this->names[$index]] = $value; + } + $logs[] = $log; + } + return $logs; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Pull/Pull.php b/lib/sebastianfeldmann/git/src/Command/Pull/Pull.php new file mode 100644 index 0000000000..6f2f64f954 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Pull/Pull.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Pull; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class Pull + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.9.4 + */ +class Pull extends Base +{ + /** + * Dry run. + * + * @var string + */ + private string $dryRun = ''; + + /** + * --ff, --no-ff + * + * @var string + */ + private string $mergeFastForward = ''; + + /** + * --ff-only + * + * @var string + */ + private string $fastForwardOnly = ''; + + /** + * Remote to pullBranch refs from + * + * @var string + */ + private string $remote = ''; + + /** + * Branch name to pullBranch + * + * @var string + */ + private string $refSpec = ''; + + /** + * Set dry run. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Pull\Pull + */ + public function dryRun(bool $bool = true): Pull + { + $this->dryRun = $this->useOption('--dry-run', $bool); + return $this; + } + + /** + * Force to use fast-forward merges only + * + * @param bool $bool + * @return \SebastianFeldmann\Git\Command\Pull\Pull + */ + public function fastForwardOnly(bool $bool = true): Pull + { + $this->fastForwardOnly = $this->useOption('--ff-only', $bool); + return $this; + } + + /** + * Allow fast-forward merge + * + * @param bool $bool + * @return $this + */ + public function mergeFastForward(bool $bool = true): Pull + { + $this->mergeFastForward = $bool ? ' --ff' : ' --no-ff'; + return $this; + } + + /** + * Set the remote to pullBranch from + * + * @param string $remote + * @return $this + */ + public function remote(string $remote): Pull + { + $this->remote = $remote; + return $this; + } + + /** + * Set the branch to pullBranch + * + * @param string $branch + * @return $this + */ + public function branch(string $branch): Pull + { + $this->refSpec = $branch; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'pullBranch' + . $this->dryRun + . $this->mergeFastForward + . $this->fastForwardOnly + . $this->getRepoAndRefSpec(); + } + + /** + * Returns the arguments for the pullBranch command + * + * @return string + */ + private function getRepoAndRefSpec(): string + { + if (!empty($this->refSpec) && empty($this->remote)) { + $this->remote = 'origin'; + } + return $this->useOption($this->remote, !empty($this->remote)) + . $this->useOption($this->refSpec, !empty($this->refSpec)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/RefLog/BranchRevs.php b/lib/sebastianfeldmann/git/src/Command/RefLog/BranchRevs.php new file mode 100644 index 0000000000..2883435878 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/RefLog/BranchRevs.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\RefLog; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class BranchRevs + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.10.0 + */ +class BranchRevs extends Base +{ + /** + * git log format + * + * @var string + */ + private string $format = ''; + + /** + * branch name + * + * @var string + */ + private string $branch; + + public function format(string $format): BranchRevs + { + $this->format = $format; + return $this; + } + + public function fromBranch(string $branch): BranchRevs + { + $this->branch = $branch; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + $format = !empty($this->format) ? ' --format=\'' . $this->format . '\'' : ''; + return 'reflog' . $format . ' ' . $this->branch; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/RevParse/GetBranch.php b/lib/sebastianfeldmann/git/src/Command/RevParse/GetBranch.php new file mode 100644 index 0000000000..5d44926618 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/RevParse/GetBranch.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\RevParse; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetBranch + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 2.3.1 + */ +class GetBranch extends Base +{ + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'rev-parse --abbrev-ref HEAD'; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/RevParse/GetCommitHash.php b/lib/sebastianfeldmann/git/src/Command/RevParse/GetCommitHash.php new file mode 100644 index 0000000000..0862d1fa4a --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/RevParse/GetCommitHash.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\RevParse; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetCommitHash + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class GetCommitHash extends Base +{ + /** + * Revision to look up. + * + * @var string + */ + private string $rev = 'HEAD'; + + /** + * Set revision to look up. + * + * @param string $revision + * @return \SebastianFeldmann\Git\Command\RevParse\GetCommitHash + */ + public function revision(string $revision): GetCommitHash + { + $this->rev = $revision; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'rev-parse --verify ' . $this->rev; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Rm/RemoveFiles.php b/lib/sebastianfeldmann/git/src/Command/Rm/RemoveFiles.php new file mode 100644 index 0000000000..2826a5bf34 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Rm/RemoveFiles.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Rm; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class RemoveFiles + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.7.0 + */ +class RemoveFiles extends Base +{ + /** + * Dry run. + * + * @var string + */ + private string $dryRun = ''; + + /** + * Cached. + * + * @var string + */ + private string $cached = ''; + + /** + * Recursive removal. + * + * @var string + */ + private string $recursive = ''; + + /** + * Files to remove. + * + * @var string[] + */ + private array $files = []; + + /** + * Set dry run. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Rm\RemoveFiles + */ + public function dryRun(bool $bool = true): RemoveFiles + { + $this->dryRun = $this->useOption('--dry-run', $bool); + return $this; + } + + /** + * Unstage and remove paths only from the index. + * + * Working tree files, whether modified or not, will be left alone.. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Rm\RemoveFiles + */ + public function cached(bool $bool = true): RemoveFiles + { + $this->cached = $this->useOption('--cached', $bool); + return $this; + } + + /** + * Allow recursive removal when a leading directory name is given. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Rm\RemoveFiles + */ + public function recursive(bool $bool = true): RemoveFiles + { + $this->recursive = $this->useOption('-r', $bool); + return $this; + } + + /** + * Files to remove. + * + * A leading directory name (e.g. `dir` to remove `dir/file1` and + * `dir/file2`) can be given to remove all files in the directory, and + * recursively all sub-directories, but this requires the `-r` option to be + * explicitly given. + * + * The command removes only the paths that are known to Git. + * + * File globbing matches across directory boundaries. Thus, given two + * directories `d` and `d2`, there is a difference between using + * `git rm 'd*'` and `git rm 'd/*'`, as the former will also remove all of + * directory `d2`. + * + * @param array $files + * @return \SebastianFeldmann\Git\Command\Rm\RemoveFiles + */ + public function files(array $files): RemoveFiles + { + $this->files = $files; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'rm' + . $this->dryRun + . $this->cached + . $this->recursive + . ' -- ' + . implode(' ', array_map('escapeshellarg', $this->files)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Status/Porcelain/PathList.php b/lib/sebastianfeldmann/git/src/Command/Status/Porcelain/PathList.php new file mode 100644 index 0000000000..f51e609c22 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Status/Porcelain/PathList.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Status\Porcelain; + +use SebastianFeldmann\Cli\Command\OutputFormatter; +use SebastianFeldmann\Git\Status\Path; + +/** + * Class PathList + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.6.0 + */ +class PathList implements OutputFormatter +{ + /** + * Nul-byte used as a separator in `--porcelain=v1 -z` output + */ + private const NUL_BYTE = "\x00"; + + /** + * Format the output + * + * @param array $output + * @return iterable + */ + public function format(array $output): iterable + { + if (empty($output)) { + return []; + } + + $statusLine = implode('', $output); + $paths = []; + + foreach ($this->parseStatusLine($statusLine) as $pathParts) { + $paths[] = new Path(...$pathParts); + } + + return $paths; + } + + /** + * Parse the status line and return a 3-tuple of path parts + * + * - 0: status code + * - 1: path + * - 2: original path, if renamed or copied + * + * @return array> + */ + private function parseStatusLine(string $statusLine): array + { + $pathParts = []; + + $parts = array_reverse($this->splitOnNulByte($statusLine)); + + while ($parts) { + $part = array_pop($parts); + $statusCode = substr($part, 0, 2); + $path = substr($part, 3); + + $originalPath = null; + if (in_array($statusCode[0], [Path::COPIED, Path::RENAMED])) { + $originalPath = array_pop($parts); + } + + $pathParts[] = [$statusCode, $path, $originalPath]; + } + + return $pathParts; + } + + /** + * Split the status line on the nul-byte + * + * @param string $statusLine + * @return array + */ + private function splitOnNulByte(string $statusLine): array + { + return explode(self::NUL_BYTE, trim($statusLine, self::NUL_BYTE)); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Status/WorkingTreeStatus.php b/lib/sebastianfeldmann/git/src/Command/Status/WorkingTreeStatus.php new file mode 100644 index 0000000000..20362ab689 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Status/WorkingTreeStatus.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Status; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetWorkingTreeStatus + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.6.0 + */ +class WorkingTreeStatus extends Base +{ + /** + * Ignore submodules. + * + * @var string + */ + private string $ignoreSubmodules = ''; + + /** + * Set ignore submodules. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Status\WorkingTreeStatus + */ + public function ignoreSubmodules(bool $bool = true): WorkingTreeStatus + { + $this->ignoreSubmodules = $this->useOption('--ignore-submodules', $bool); + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'status --porcelain=v1 -z' + . $this->ignoreSubmodules; + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/Tag/GetTags.php b/lib/sebastianfeldmann/git/src/Command/Tag/GetTags.php new file mode 100644 index 0000000000..8001905fe3 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/Tag/GetTags.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Tag; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetCurrentTag + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 2.3.0 + */ +class GetTags extends Base +{ + /** + * Commit to check for a tag + * + * @var string + */ + private string $hash = 'HEAD'; + + /** + * Set the hash you want to check for tags, HEAD by default + * + * @param string $hash + * @return \SebastianFeldmann\Git\Command\Tag\GetTags + */ + public function pointingTo(string $hash): GetTags + { + $this->hash = $hash; + return $this; + } + + /** + * Return the command to execute. + * + * @return string + */ + protected function getGitCommand(): string + { + return 'tag --points-at ' . escapeshellarg($this->hash); + } +} diff --git a/lib/sebastianfeldmann/git/src/Command/WriteTree/CreateTreeObject.php b/lib/sebastianfeldmann/git/src/Command/WriteTree/CreateTreeObject.php new file mode 100644 index 0000000000..38f181a8e7 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Command/WriteTree/CreateTreeObject.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\WriteTree; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class CreateTreeObject + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.7.0 + */ +class CreateTreeObject extends Base +{ + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'write-tree'; + } +} diff --git a/lib/sebastianfeldmann/git/src/CommitMessage.php b/lib/sebastianfeldmann/git/src/CommitMessage.php new file mode 100644 index 0000000000..b8451a636f --- /dev/null +++ b/lib/sebastianfeldmann/git/src/CommitMessage.php @@ -0,0 +1,359 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git; + +use RuntimeException; + +/** + * Class CommitMessage + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class CommitMessage +{ + /** + * Commit Message content + * + * This includes lines that are comments. + * + * @var string + */ + private string $rawContent; + + /** + * Content split lines + * + * This includes lines that are comments. + * + * @var array + */ + private array $rawLines; + + /** + * Amount of lines + * + * This includes lines that are comments + * + * @var int + */ + private int $rawLineCount; + + /** + * The comment character + * + * @var string + */ + private string $commentCharacter; + + /** + * All non comment lines + * + * @var array + */ + private array $contentLines; + + /** + * Get the number of lines + * + * This excludes lines which are comments. + * + * @var int + */ + private int $contentLineCount; + + /** + * Commit Message content + * + * This excludes lines that are comments. + * + * @var string + */ + private string $content; + + /** + * CommitMessage constructor + * + * @param string $content + * @param string $commentCharacter + */ + public function __construct(string $content, string $commentCharacter = '#') + { + $this->rawContent = $content; + $this->rawLines = empty($content) ? [] : $this->splitByLine($content); + $this->rawLineCount = count($this->rawLines); + $this->commentCharacter = $commentCharacter; + $this->contentLines = $this->getContentLines($this->rawLines, $commentCharacter); + $this->contentLineCount = count($this->contentLines); + $this->content = implode(PHP_EOL, $this->contentLines); + } + + /** + * Is message empty. + * + * @return bool + */ + public function isEmpty(): bool + { + return empty($this->content); + } + + /** + * Is this a fixup commit + * + * @return bool + */ + public function isFixup(): bool + { + return str_starts_with($this->rawContent, 'fixup!'); + } + + /** + * Is this a squash commit + * + * @return bool + */ + public function isSquash(): bool + { + return str_starts_with($this->rawContent, 'squash!'); + } + + /** + * Get commit message content + * + * This excludes lines that are comments. + * + * @return string + */ + public function getContent(): string + { + return $this->content; + } + + /** + * Get complete commit message content + * + * This includes lines that are comments. + * + * @return string + */ + public function getRawContent(): string + { + return $this->rawContent; + } + + /** + * Return all lines + * + * This includes lines that are comments. + * + * @return array + */ + public function getLines(): array + { + return $this->rawLines; + } + + /** + * Return line count + * + * This includes lines that are comments. + * + * @return int + */ + public function getLineCount(): int + { + return $this->rawLineCount; + } + + /** + * Return content line count + * + * This doesn't includes lines that are comments. + * + * @return int + */ + public function getContentLineCount(): int + { + return $this->contentLineCount; + } + + /** + * Get a specific line + * + * @param int $index + * @return string + */ + public function getLine(int $index): string + { + return $this->rawLines[$index] ?? ''; + } + + /** + * Get a specific content line + * + * @param int $index + * @return string + */ + public function getContentLine(int $index): string + { + return $this->contentLines[$index] ?? ''; + } + + /** + * Return first line + * + * @return string + */ + public function getSubject(): string + { + return $this->contentLines[0] ?? ''; + } + + /** + * Return content from line nr. 3 to the last line + * + * @return string + */ + public function getBody(): string + { + return implode(PHP_EOL, $this->getBodyLines()); + } + + /** + * Return lines from line nr. 3 to the last line + * + * @return array + */ + public function getBodyLines(): array + { + return $this->contentLineCount < 3 ? [] : array_slice($this->contentLines, 2); + } + + /** + * Returns all comment lines as string + * + * @return string + */ + public function getComments(): string + { + return implode(PHP_EOL, $this->getCommentLines()); + } + + /** + * Returns all comment lines in an array + * + * @return array + */ + public function getCommentLines(): array + { + $comments = []; + $allCommentFromHere = false; + foreach ($this->getLines() as $line) { + if ($this->isCommentLine($line) || $allCommentFromHere) { + if (!$allCommentFromHere && $this->isScissorLine($line)) { + $allCommentFromHere = true; + } + $comments[] = $line; + } + } + return $comments; + } + + /** + * Get the comment character + * + * Comment character defaults to '#'. + * + * @return string + */ + public function getCommentCharacter(): string + { + return $this->commentCharacter; + } + + /** + * Get the lines that are not comments + * + * @param array $rawLines + * @param string $commentCharacter + * @return array + */ + private function getContentLines(array $rawLines, string $commentCharacter): array + { + $lines = []; + foreach ($rawLines as $line) { + // if we handle a comment line + if ($this->isCommentLine($line)) { + // check if we should ignore all following lines + if ($this->isScissorLine($line)) { + break; + } + // or only the current one + continue; + } + $lines[] = $line; + } + return $lines; + } + + /** + * Is the line a comment line + * + * @param string $line + * @return bool + */ + private function isCommentLine(string $line): bool + { + return str_starts_with(trim($line), $this->commentCharacter); + } + + /** + * Check if the scissor operator is used to mark all following lines as comment + * + * @param string $line + * @return bool + */ + public function isScissorLine(string $line): bool + { + return str_contains($line, '------------------------ >8 ------------------------'); + } + + /** + * Split message into separate lines + * + * @param string $content + * @return array + */ + private function splitByLine(string $content): array + { + $lines = (array) preg_split("/\\r\\n|\\r|\\n/", $content); + return array_filter($lines, fn($line) => is_string($line)); + } + + /** + * Create CommitMessage from file + * + * @param string $path + * @param string $commentCharacter + * @return \SebastianFeldmann\Git\CommitMessage + */ + public static function createFromFile(string $path, string $commentCharacter = '#'): CommitMessage + { + if (!file_exists($path)) { + throw new RuntimeException('Commit message file not found'); + } + return new CommitMessage((string) file_get_contents($path), $commentCharacter); + } +} diff --git a/lib/sebastianfeldmann/git/src/Diff/Change.php b/lib/sebastianfeldmann/git/src/Diff/Change.php new file mode 100644 index 0000000000..3a3644f1d4 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Diff/Change.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Diff; + +/** + * Class Change. + * + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.2.0 + */ +class Change +{ + /** + * Optional file header. + * + * @var string + */ + private string $header; + + /** + * Pre range. + * + * @var array{from: int|null, to: int|null} + */ + private array $pre; + + /** + * Post range. + * + * @var array{from: int|null, to: int|null} + */ + private array $post; + + /** + * List of changed lines. + * + * @var \SebastianFeldmann\Git\Diff\Line[] + */ + private array $lines = []; + + /** + * Chan + * ge constructor. + * + * @param string $ranges + * @param string $header + */ + public function __construct(string $ranges, string $header = '') + { + $this->header = $header; + $this->splitRanges($ranges); + } + + /** + * Header getter + * + * @return string + */ + public function getHeader(): string + { + return $this->header; + } + + /** + * Pre range getter + * + * @return array + */ + public function getPre(): array + { + return $this->pre; + } + + /** + * Post range getter + * + * @return array + */ + public function getPost(): array + { + return $this->post; + } + + /** + * Return list of changed lines + * + * @return \SebastianFeldmann\Git\Diff\Line[] + */ + public function getLines(): array + { + return $this->lines; + } + + /** + * Return list of added content + * + * @return string[] + */ + public function getAddedContent(): array + { + $added = []; + foreach ($this->lines as $line) { + if ($line->getOperation() === Line::ADDED) { + $added[] = $line->getContent(); + } + } + return $added; + } + + /** + * Add a line to the change + * + * @param \SebastianFeldmann\Git\Diff\Line $line + * @return void + */ + public function addLine(Line $line): void + { + $this->lines[] = $line; + } + + /** + * Parse ranges and split them into pre and post range + * + * @param string $ranges + * @return void + */ + private function splitRanges(string $ranges): void + { + $matches = []; + if (!preg_match('#^[\-|+](\d+)(?:,(\d+))? [\-+](\d+)(?:,(\d+))?$#', $ranges, $matches)) { + throw new \RuntimeException('invalid ranges: ' . $ranges); + } + + $matches = array_map( + function ($value) { + if (strlen($value) === 0) { + return null; + } + return $value; + }, + $matches + ); + + $this->pre = [ + 'from' => isset($matches[1]) ? (int) $matches[1] : null, + 'to' => isset($matches[2]) ? (int) $matches[2] : null, + ]; + + $this->post = [ + 'from' => isset($matches[3]) ? (int) $matches[3] : null, + 'to' => isset($matches[4]) ? (int) $matches[4] : null, + ]; + } +} diff --git a/lib/sebastianfeldmann/git/src/Diff/File.php b/lib/sebastianfeldmann/git/src/Diff/File.php new file mode 100644 index 0000000000..61dd4cbc71 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Diff/File.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Diff; + +/** + * Class File + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.2.0 + */ +class File +{ + public const OP_DELETED = 'deleted'; + public const OP_CREATED = 'created'; + public const OP_MODIFIED = 'modified'; + public const OP_RENAMED = 'renamed'; + public const OP_COPIED = 'copied'; + + /** + * List of changes. + * + * @var \SebastianFeldmann\Git\Diff\Change[] + */ + private array $changes = []; + + /** + * Filename + * + * @var string + */ + private string $name; + + /** + * Operation performed on the file. + * + * @var string + */ + private string $operation; + + /** + * File constructor. + * + * @param string $name + * @param string $operation + */ + public function __construct(string $name, string $operation) + { + $this->operation = $operation; + $this->name = $name; + } + + /** + * Returns the filename. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Returns the performed operation. + * + * @return string + */ + public function getOperation(): string + { + return $this->operation; + } + + /** + * Returns the list of changes in this file. + * + * @return \SebastianFeldmann\Git\Diff\Change[] + */ + public function getChanges(): array + { + return $this->changes; + } + + /** + * Add a change to the list of changes. + * + * @param \SebastianFeldmann\Git\Diff\Change $change + * @return void + */ + public function addChange(Change $change): void + { + $this->changes[] = $change; + } + + /** + * Return all newly added content + * + * @return string[] + */ + public function getAddedContent(): array + { + $content = []; + if ($this->operation === self::OP_DELETED) { + return $content; + } + + foreach ($this->changes as $change) { + $content = array_merge($content, $change->getAddedContent()); + } + + return $content; + } +} diff --git a/lib/sebastianfeldmann/git/src/Diff/FilterUtil.php b/lib/sebastianfeldmann/git/src/Diff/FilterUtil.php new file mode 100644 index 0000000000..72497c3358 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Diff/FilterUtil.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Diff; + +/** + * Filter utility class + * + * + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.9.0 + */ +abstract class FilterUtil +{ + /** + * Remove all invalid filter options + * + * @param array $filter + * @return array + */ + public static function sanitize(array $filter): array + { + return array_filter($filter, function ($e) { + return in_array($e, ['A', 'C', 'D', 'M', 'R', 'T', 'U', 'X', 'B', '*']); + }); + } +} diff --git a/lib/sebastianfeldmann/git/src/Diff/Line.php b/lib/sebastianfeldmann/git/src/Diff/Line.php new file mode 100644 index 0000000000..afe807e4ac --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Diff/Line.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Diff; + +/** + * Class Line. + * + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.2.0 + */ +class Line +{ + /** + * Possible line operations + */ + public const ADDED = 'added'; + public const REMOVED = 'removed'; + public const EXISTED = 'existed'; + + /** + * Map diff output to file operation + * @var array + */ + public static $opsMap = [ + '+' => self::ADDED, + '-' => self::REMOVED, + ' ' => self::EXISTED + ]; + + /** + * @var string + */ + private string $operation; + + /** + * @var string + */ + private string $content; + + /** + * Line constructor. + * + * @param string $operation + * @param string $content + */ + public function __construct(string $operation, string $content) + { + if (!in_array($operation, self::$opsMap)) { + throw new \RuntimeException('invalid line operation: ' . $operation); + } + $this->operation = $operation; + $this->content = $content; + } + + /** + * Operation getter. + * + * @return string + */ + public function getOperation(): string + { + return $this->operation; + } + + /** + * Content getter. + * + * @return string + */ + public function getContent(): string + { + return $this->content; + } +} diff --git a/lib/sebastianfeldmann/git/src/Log/Commit.php b/lib/sebastianfeldmann/git/src/Log/Commit.php new file mode 100644 index 0000000000..8af39f9b93 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Log/Commit.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Log; + +/** + * Class Commit + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.2.0 + */ +class Commit +{ + /** + * @var string + */ + private string $hash; + + /** + * @var array + */ + private array $names; + + /** + * @var string + */ + private string $subject; + + /** + * @var string + */ + private string $body; + + /** + * @var \DateTimeImmutable + */ + private \DateTimeImmutable $date; + + /** + * @var string + */ + private string $author; + + /** + * Commit constructor + * + * @param string $hash + * @param array $names + * @param string $subject + * @param string $body + * @param \DateTimeImmutable $date + * @param string $author + */ + public function __construct( + string $hash, + array $names, + string $subject, + string $body, + \DateTimeImmutable $date, + string $author + ) { + $this->hash = $hash; + $this->names = $names; + $this->subject = $subject; + $this->body = $body; + $this->date = $date; + $this->author = $author; + } + + /** + * Hash getter + * + * @return string + */ + public function getHash(): string + { + return $this->hash; + } + + /** + * Does the commit have names + * + * @return bool + */ + public function hasNames(): bool + { + return !empty($this->names); + } + + /** + * Names getter + * + * @return array + */ + public function getNames(): array + { + return $this->names; + } + + /** + * Description getter + * + * @deprecated + * + * @return string + */ + public function getDescription(): string + { + return $this->getSubject(); + } + + /** + * Subject getter + * + * @return string + */ + public function getSubject(): string + { + return $this->subject; + } + + /** + * Body getter + * + * @return string + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Date getter + * + * @return \DateTimeImmutable + */ + public function getDate(): \DateTimeImmutable + { + return $this->date; + } + + /** + * Author getter + * + * @return string + */ + public function getAuthor(): string + { + return $this->author; + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Base.php b/lib/sebastianfeldmann/git/src/Operator/Base.php new file mode 100644 index 0000000000..264d0bbed6 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Base.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use SebastianFeldmann\Cli\Command\Runner; +use SebastianFeldmann\Git\Repository; + +/** + * Class Base + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +abstract class Base +{ + /** + * Runner to execute git system calls. + * + * @var \SebastianFeldmann\Cli\Command\Runner + */ + protected Runner $runner; + + /** + * Git repository to use. + * + * @var \SebastianFeldmann\Git\Repository + */ + protected Repository $repo; + + /** + * Base constructor. + * + * @param \SebastianFeldmann\Cli\Command\Runner $runner + * @param \SebastianFeldmann\Git\Repository $repo + */ + public function __construct(Runner $runner, Repository $repo) + { + $this->runner = $runner; + $this->repo = $repo; + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Config.php b/lib/sebastianfeldmann/git/src/Operator/Config.php new file mode 100644 index 0000000000..08c094b719 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Config.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use RuntimeException; +use SebastianFeldmann\Cli\Command\Runner\Result; +use SebastianFeldmann\Git\Command\Config\Get; +use SebastianFeldmann\Git\Command\Config\GetVar; +use SebastianFeldmann\Git\Command\Config\ListSettings; +use SebastianFeldmann\Git\Command\Config\ListVars; +use SebastianFeldmann\Git\Command\Config\MapValues; + +/** + * Class Config + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.0.2 + */ +class Config extends Base +{ + private const TYPE_CONFIG = 'config'; + private const TYPE_VAR = 'var'; + + /** + * @deprectaed since 3.14.0 replaced by `hasSetting` + */ + public function has(string $name): bool + { + trigger_error('\'has\' should be replaced by \'hasSetting\'', E_USER_DEPRECATED); + return $this->hasSetting($name); + } + + /** + * Does git have a config value set + * + * @param string $name + * @return boolean + */ + public function hasSetting(string $name): bool + { + return $this->hasIt(self::TYPE_CONFIG, $name); + } + + /** + * Does git have a var set + * + * @param string $name + * @return boolean + */ + public function hasVar(string $name): bool + { + return $this->hasIt(self::TYPE_VAR, $name); + } + + /** + * Use the `config` or `var` method to check + * + * @param string $type + * @param string $name + * @return bool + */ + private function hasIt(string $type, string $name): bool + { + $method = $type . 'Command'; + try { + $result = $this->{$method}($name); + } catch (RuntimeException $exception) { + return false; + } + + return $result->isSuccessful(); + } + + /** + * @deprecated since 3.14.0 replaced by `getSetting` + */ + public function get(string $name): string + { + trigger_error('\'get\' should be replaced by \'getSetting\'', E_USER_DEPRECATED); + return $this->getSetting($name); + } + + /** + * Get a configuration value + * + * @param string $name + * @return string + */ + public function getSetting(string $name): string + { + $result = $this->configCommand($name); + return $result->getBufferedOutput()[0] ?? ''; + } + + /** + * Get a var value + * + * @param string $name + * @return string + */ + public function getVar(string $name): string + { + $result = $this->varCommand($name); + return $result->getBufferedOutput()[0] ?? ''; + } + + /** + * @deprecated since 3.14.0 replaced by `getSettingSafely` + */ + public function getSafely(string $name, string $default = ''): string + { + trigger_error('\'getSafely\' should be replaced by \'getSettingSafely\'', E_USER_DEPRECATED); + return $this->getSettingSafely($name, $default); + } + + /** + * Get config values without throwing exceptions. + * + * You can provide a default value to return. + * By default, the return value on unset config values is the empty string. + * + * @param string $name Name of the config value to retrieve + * @param string $default Value to return if config value is not set, empty string by default + * @return string + */ + public function getSettingSafely(string $name, string $default = ''): string + { + return $this->hasSetting($name) ? $this->getSetting($name) : $default; + } + + /** + * Get var value without throwing an exception if it does not exist + * + * @param string $name + * @param string $default + * @return string + */ + public function getVarSafely(string $name, string $default = ''): string + { + return $this->hasVar($name) ? $this->getVar($name) : $default; + } + + /** + * Return a map of all configuration settings. + * + * For example: ['color.branch' => 'auto', 'color.diff' => 'auto'] + * + * @return array + */ + public function getSettings(): iterable + { + $cmd = new ListSettings($this->repo->getRoot()); + $res = $this->runner->run($cmd, new MapValues()); + return $res->getFormattedOutput(); + } + + + /** + * Return a map of all defined git vars + * + * For example: ['color.branch' => 'auto', 'color.diff' => 'auto'] + * + * @return array + */ + public function getVars(): iterable + { + $cmd = new ListVars($this->repo->getRoot()); + $res = $this->runner->run($cmd, new MapValues()); + return $res->getFormattedOutput(); + } + + /** + * Run the get config command. + * + * @param string $name + * @return \SebastianFeldmann\Cli\Command\Runner\Result + */ + private function configCommand(string $name): Result + { + $cmd = (new Get($this->repo->getRoot())); + $cmd->name($name); + return $this->runner->run($cmd); + } + + /** + * Return the var command + * + * @param string $name + * @return \SebastianFeldmann\Cli\Command\Runner\Result + */ + private function varCommand(string $name): Result + { + $cmd = (new GetVar($this->repo->getRoot())); + $cmd->name($name); + return $this->runner->run($cmd); + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Diff.php b/lib/sebastianfeldmann/git/src/Operator/Diff.php new file mode 100644 index 0000000000..95ece49160 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Diff.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use SebastianFeldmann\Git\Command\Apply\ApplyPatch; +use SebastianFeldmann\Git\Command\Diff\Compare; +use SebastianFeldmann\Git\Command\DiffIndex\GetUnstagedPatch; +use SebastianFeldmann\Git\Command\Diff\ChangedFiles as DiffChangedFiles; +use SebastianFeldmann\Git\Command\DiffTree\ChangedFiles as DiffTreeChangedFiles; +use SebastianFeldmann\Git\Command\WriteTree\CreateTreeObject; + +/** + * Diff operator + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.2.0 + */ +class Diff extends Base +{ + /** + * Returns a list of files and their changes. + * + * @param string $from + * @param string $to + * @return \SebastianFeldmann\Git\Diff\File[] + */ + public function compare(string $from, string $to): array + { + $compare = (new Compare($this->repo->getRoot()))->revisions($from, $to) + ->ignoreWhitespacesAtEndOfLine(); + + $result = $this->runner->run($compare, new Compare\FullDiffList()); + + return $result->getFormattedOutput(); + } + + /** + * Returns a list of files and their changes staged for the next commit + * + * @param string $to + * @return \SebastianFeldmann\Git\Diff\File[] + */ + public function compareIndexTo(string $to = 'head'): array + { + $compare = (new Compare($this->repo->getRoot()))->indexTo($to) + ->withContextLines(0) + ->ignoreWhitespacesAtEndOfLine(); + + $result = $this->runner->run($compare, new Compare\FullDiffList()); + + return $result->getFormattedOutput(); + } + + /** + * Returns a list of files and their changes not yet staged + * + * @param string $to + * @return \SebastianFeldmann\Git\Diff\File[] + */ + public function compareTo(string $to = 'HEAD'): array + { + $compare = (new Compare($this->repo->getRoot()))->to($to) + ->ignoreSubmodules() + ->withContextLines(0); + + $result = $this->runner->run($compare, new Compare\FullDiffList()); + + return $result->getFormattedOutput(); + } + + /** + * Uses 'diff-tree' to list the files that changed between two revisions + * + * @param string $from + * @param string $to + * @param array $filter + * @return string[] + */ + public function getChangedFiles(string $from, string $to, array $filter = []): array + { + $cmd = (new DiffTreeChangedFiles($this->repo->getRoot()))->fromRevision($from) + ->toRevision($to) + ->useFilter($filter); + $result = $this->runner->run($cmd); + + return $result->getBufferedOutput(); + } + + /** + * Uses 'diff' to list the files that changed + * + * List files that changed in a branch (to) since it diverged (branched of) from another branch (from). + * Does not include changes that are not reachable from to. + * + * @param string $from Base branch + * @param string $to Diverged (feature) branch + * @param array $filter A|C|D|M|R|T|U|X|B Added, Copied, Deleted, Modified, Renamed, Type changed... + * @return string[] + */ + public function getChangedFilesSinceBranch(string $from, string $to, array $filter = []): array + { + $cmd = (new DiffChangedFiles($this->repo->getRoot()))->mergeBase() + ->fromRevision($from) + ->toRevision($to) + ->useFilter($filter); + $result = $this->runner->run($cmd); + + return $result->getBufferedOutput(); + } + + /** + * Uses 'diff-tree' to list the files with a given suffix that changed between two revisions + * + * @param string $from + * @param string $to + * @param string $suffix + * @param array $filter + * @return string[] + */ + public function getChangedFilesOfType(string $from, string $to, string $suffix, array $filter = []): array + { + $suffix = strtolower($suffix); + $cmd = (new DiffTreeChangedFiles($this->repo->getRoot()))->fromRevision($from) + ->toRevision($to) + ->useFilter($filter); + $result = $this->runner->run($cmd); + $files = $result->getBufferedOutput(); + $filesByType = []; + + foreach ($files as $file) { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $filesByType[$ext][] = $file; + } + return $filesByType[$suffix] ?? []; + } + + /** + * Returns a binary diff of unstaged changes to the working tree that can be + * applied with `git-apply`. + * + * @return string|null String patch, if there are unstaged changes; null otherwise. + */ + public function getUnstagedPatch(): ?string + { + $treeCmd = new CreateTreeObject($this->repo->getRoot()); + $treeResult = $this->runner->run($treeCmd); + + $treeId = null; + if ($treeResult->isSuccessful()) { + $treeId = trim($treeResult->getStdOut()); + } + + $cmd = (new GetUnstagedPatch($this->repo->getRoot()))->tree($treeId); + $result = $this->runner->run($cmd); + + // A status code of 1 means there were differences, and we have a patch. + if ($result->getCode() === 1) { + return $result->getStdOut(); + } + + return null; + } + + /** + * Applies the supplied diff patches to files. + * + * @param string[] $patches An array of paths to patch files. + * @param bool $disableAutoCrlfSetting If true, explicitly set core.autocrlf + * to "false" to override the global Git configuration. + * @return bool True if the patches apply cleanly. + */ + public function applyPatches(array $patches, bool $disableAutoCrlfSetting = false): bool + { + $cmd = (new ApplyPatch($this->repo->getRoot())) + ->patches($patches) + ->whitespace('nowarn'); + + if ($disableAutoCrlfSetting === true) { + $cmd->setConfigParameter('core.autocrlf', false); + } + + $result = $this->runner->run($cmd); + + return $result->isSuccessful(); + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Index.php b/lib/sebastianfeldmann/git/src/Operator/Index.php new file mode 100644 index 0000000000..4ef8fe392e --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Index.php @@ -0,0 +1,314 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use RuntimeException; +use SebastianFeldmann\Git\Command\Add\AddFiles; +use SebastianFeldmann\Git\Command\DiffIndex\GetStagedFiles; +use SebastianFeldmann\Git\Command\DiffIndex\GetStagedFiles\FilterByStatus; +use SebastianFeldmann\Git\Command\RevParse\GetCommitHash; +use SebastianFeldmann\Git\Command\Rm\RemoveFiles; +use SebastianFeldmann\Git\Diff\FilterUtil; + +/** + * Index Operator + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class Index extends Base +{ + /** + * Cached list of changed files + * + * files[FILTER][file1, file2, file3] + * + * @var array> + */ + private array $files = []; + + /** + * Changed files by file type + * + * @var array>> + */ + private array $types = []; + + /** + * Default diff filter used + * + * @var array + */ + private array $defaultDiffFilter = ['A', 'C', 'M', 'R']; + + /** + * Get the list of files that changed + * + * @param array $diffFilter List of status you want to get returned, choose from [A,C,D,M,R,T,U,X,B,*] + * @return array + */ + public function getStagedFiles(array $diffFilter = []): array + { + $filter = empty($diffFilter) ? $this->defaultDiffFilter : $diffFilter; + return $this->retrieveStagedFiles($filter); + } + + /** + * Where there files changed of a given type + * + * @param string $suffix + * @return bool + */ + public function hasStagedFilesOfType(string $suffix): bool + { + return count($this->getStagedFilesOfType($suffix)) > 0; + } + + /** + * Return list of changed files of a given type + * + * @param string $suffix + * @param array $diffFilter + * @return array + */ + public function getStagedFilesOfType(string $suffix, array $diffFilter = []): array + { + $suffix = strtolower($suffix); + $sanitized = FilterUtil::sanitize($diffFilter); + $filter = empty($sanitized) ? $this->defaultDiffFilter : $sanitized; + $filesByType = $this->retrieveStagedFilesByType($filter); + + return $filesByType[$suffix] ?? []; + } + + /** + * Return list of changed files of a given types + * + * @param array $suffixes + * @param array $diffFilter + * @return array + */ + public function getStagedFilesOfTypes(array $suffixes, array $diffFilter = []): array + { + $suffixes = array_map('strtolower', $suffixes); + $sanitized = FilterUtil::sanitize($diffFilter); + $filter = empty($sanitized) ? $this->defaultDiffFilter : $sanitized; + $filesByType = $this->retrieveStagedFilesByType($filter); + + $files = []; + foreach ($suffixes as $suffix) { + if (!empty($filesByType[$suffix])) { + $files = array_merge($files, $filesByType[$suffix]); + } + } + return $files; + } + + /** + * Update the index using the current content found in the working tree + * + * @param array $files + * @return bool + */ + public function addFilesToIndex(array $files): bool + { + $cmd = (new AddFiles($this->repo->getRoot()))->files($files); + $result = $this->runner->run($cmd); + + return $result->isSuccessful(); + } + + /** + * Update the index just where it already has an entry matching + * + * This removes as well as modifies index entries to match the working tree, + * but adds no new files. + * + * @param array $files + * @return bool + */ + public function updateIndex(array $files): bool + { + $cmd = (new AddFiles($this->repo->getRoot()))->files($files)->update(); + $result = $this->runner->run($cmd); + + return $result->isSuccessful(); + } + + /** + * Update the index not only where the working tree has a file matching + * but also where the index already has an entry. + * + * This adds, modifies, and removes index entries to match the working tree. + * + * If `$ignoreRemoval` is `true`, files removed in the working tree are + * ignored and not removed from the index. + * + * @param array $files + * @param bool $ignoreRemoval Ignore files that have been removed from the working tree + * @return bool + */ + public function updateIndexToMatchWorkingTree(array $files, bool $ignoreRemoval = false): bool + { + $all = !$ignoreRemoval; + + $cmd = (new AddFiles($this->repo->getRoot())) + ->files($files) + ->all($all) + ->noAll($ignoreRemoval); + + $result = $this->runner->run($cmd); + + return $result->isSuccessful(); + } + + /** + * Record only the fact that the path will be added later + * + * An entry for the path is placed in the index with no content. + * + * @param array $files + * @return bool + */ + public function recordIntentToAddFiles(array $files): bool + { + $cmd = (new AddFiles($this->repo->getRoot()))->files($files)->intentToAdd(); + $result = $this->runner->run($cmd); + + return $result->isSuccessful(); + } + + /** + * Remove files from the working tree and from the index + * + * @param array $files The files to remove. + * @param bool $recursive Allow recursive removal when a leading directory name is given + * @param bool $cachedOnly Unstage and remove paths only from the index. + * The working tree is untouched. + * @return bool + */ + public function removeFiles( + array $files, + bool $recursive = false, + bool $cachedOnly = false + ): bool { + $cmd = (new RemoveFiles($this->repo->getRoot())) + ->files($files) + ->recursive($recursive) + ->cached($cachedOnly); + + $result = $this->runner->run($cmd); + + return $result->isSuccessful(); + } + + /** + * Resolve the list of files that changed + * + * @param array $diffFilter + * @return array + */ + private function retrieveStagedFiles(array $diffFilter): array + { + if (!$this->isHeadValid()) { + return []; + } + + if ($this->isCached($diffFilter)) { + return $this->retrieveFromCache($diffFilter); + } + + $cmd = new GetStagedFiles($this->repo->getRoot()); + $formatter = new FilterByStatus($diffFilter); + $result = $this->runner->run($cmd, $formatter); + $files = $result->getFormattedOutput(); + $this->cacheFiles($diffFilter, $files); + + return $files; + } + + /** + * Check if the staged files are cached + * + * @param array $diffStatus + * @return bool + */ + private function isCached(array $diffStatus): bool + { + return isset($this->files[implode($diffStatus)]); + } + + /** + * Cache staged file by requested status + * + * @param array $diffFilter + * @param array $files + * @return void + */ + private function cacheFiles(array $diffFilter, array $files): void + { + $this->files[implode($diffFilter)] = $files; + } + + /** + * Retrieve files from cache + * + * @param array $diffFilter + * @return array + */ + private function retrieveFromCache(array $diffFilter): array + { + return $this->files[implode($diffFilter)]; + } + + /** + * Sort files by file suffix + * + * @param array $diffFilter + * @return array> + */ + private function retrieveStagedFilesByType(array $diffFilter): array + { + $key = implode($diffFilter); + + if (!isset($this->types[$key])) { + $this->types[$key] = []; + foreach ($this->retrieveStagedFiles($diffFilter) as $file) { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $this->types[$key][$ext][] = $file; + } + } + return $this->types[$key]; + } + + /** + * Check head validity + * + * @return bool + */ + private function isHeadValid(): bool + { + try { + $cmd = new GetCommitHash($this->repo->getRoot()); + $result = $this->runner->run($cmd); + return $result->isSuccessful(); + } catch (RuntimeException $e) { + // if we do not have a permission error the current head is just invalid + if ($e->getCode() !== 128) { + return false; + } + throw $e; + } + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Info.php b/lib/sebastianfeldmann/git/src/Operator/Info.php new file mode 100644 index 0000000000..05bdf9fe0d --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Info.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use SebastianFeldmann\Git\Command\Branch\ListRemote; +use SebastianFeldmann\Git\Command\Describe\GetCurrentTag; +use SebastianFeldmann\Git\Command\Describe\GetMostRecentTag; +use SebastianFeldmann\Git\Command\LsTree\GetFiles; +use SebastianFeldmann\Git\Command\MergeBase\MergeBase; +use SebastianFeldmann\Git\Command\RevParse\GetBranch; +use SebastianFeldmann\Git\Command\RevParse\GetCommitHash; +use SebastianFeldmann\Git\Command\Tag\GetTags; + +/** + * Class Info + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 1.0.8 + */ +class Info extends Base +{ + /** + * Returns the tag of the current commit + * + * @return string + */ + public function getCurrentTag(): string + { + $cmd = new GetCurrentTag($this->repo->getRoot()); + $result = $this->runner->run($cmd); + + return trim($result->getStdOut()); + } + + /** + * Returns the most recent tag + * + * @param string $ignore Unix glob to ignore tags e.g. **-RC* + * @return string + */ + public function getMostRecentTag(string $ignore = ''): string + { + $cmd = new GetMostRecentTag($this->repo->getRoot()); + $cmd->ignore($ignore); + + $result = $this->runner->run($cmd); + + return trim($result->getStdOut()); + } + + /** + * Returns the most recent tag before the given commit + * + * @param string $hash + * @param string $ignore Unix glob to ignore tags e.g. **-RC* + * @return string + */ + public function getMostRecentTagBefore(string $hash, string $ignore = ''): string + { + $cmd = new GetMostRecentTag($this->repo->getRoot()); + $cmd->ignore($ignore); + $cmd->before($hash); + + $result = $this->runner->run($cmd); + + return trim($result->getStdOut()); + } + + /** + * Returns a list of tags for a given commit hash + * + * @param string $hash + * @return string[] + */ + public function getTagsPointingTo(string $hash): array + { + $cmd = new GetTags($this->repo->getRoot()); + $cmd->pointingTo($hash); + + $result = $this->runner->run($cmd); + + return $result->getBufferedOutput(); + } + + /** + * Returns the hash of the current commit + * + * @return string + */ + public function getCurrentCommitHash(): string + { + $cmd = new GetCommitHash($this->repo->getRoot()); + $result = $this->runner->run($cmd); + + return trim($result->getStdOut()); + } + + /** + * Returns the current branch name + * + * @return string + */ + public function getCurrentBranch(): string + { + $cmd = new GetBranch($this->repo->getRoot()); + $result = $this->runner->run($cmd); + + return trim($result->getStdOut()); + } + + public function getMergeBase(string $base, string $branch): string + { + $cmd = (new MergeBase($this->repo->getRoot()))->ofBranch($branch)->relativeTo($base); + $result = $this->runner->run($cmd); + + return trim($result->getStdOut()); + } + + /** + * Return all files in the repository matching a given path + * + * This will return all files in the repository if no path is given. + * + * @param string $path + * @param string $tree + * @return string[] + */ + public function getFilesInTree(string $path = '', string $tree = 'HEAD'): array + { + $cmd = new GetFiles($this->repo->getRoot()); + $cmd->inPath($path); + $cmd->fromTree($tree); + + $result = $this->runner->run($cmd); + return $result->getBufferedOutput(); + } + + /** + * Returns all the branches available on the remote git + * + * @return array + */ + public function getRemoteBranches(): array + { + $cmd = new ListRemote($this->repo->getRoot()); + $result = $this->runner->run($cmd); + + return $result->getBufferedOutput(); + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Log.php b/lib/sebastianfeldmann/git/src/Operator/Log.php new file mode 100644 index 0000000000..16cfd21d8a --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Log.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use SebastianFeldmann\Git\Command\Log\ChangedFiles; +use SebastianFeldmann\Git\Command\Log\Commits; +use SebastianFeldmann\Git\Command\Log\Commits\Xml; +use SebastianFeldmann\Git\Command\Output\Exploded; +use SebastianFeldmann\Git\Command\RefLog\BranchRevs; + +/** + * Class Log + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class Log extends Base +{ + /** + * Get the list of files that changed since a given revision. + * + * @param string $revision + * @param array $filter + * @return array + */ + public function getChangedFilesSince(string $revision, array $filter = []): array + { + $cmd = (new ChangedFiles($this->repo->getRoot()))->byRange($revision)->withDiffFilter($filter); + $result = $this->runner->run($cmd); + + return $result->getBufferedOutput(); + } + + /** + * Get the list of files that changed since a given revision. + * + * @param array $revisions + * @return array + */ + public function getChangedFilesInRevisions(array $revisions): array + { + $cmd = (new ChangedFiles($this->repo->getRoot()))->byRevisions(...$revisions); + $result = $this->runner->run($cmd); + + return $result->getBufferedOutput(); + } + + /** + * Uses the reflog to return all commit hashes for a branch + * + * @param string $branch + * @return array + * @throws \Exception + */ + public function getBranchRevsFromRefLog(string $branch): array + { + $result = $this->runner->run( + (new BranchRevs($this->repo->getRoot()))->format('%h<--<|>-->%gs')->fromBranch($branch), + new Exploded('<--<|>-->', ['shortHash', 'subject']) + ); + $logs = $result->getFormattedOutput(); + $revs = []; + foreach ($logs as $log) { + if (stripos($log['subject'], 'branch: Created from') === false) { + $revs[] = $log['shortHash']; + } + } + return $revs; + } + + /** + * Uses the reflog to return the start hash of given branch + * + * @param string $branch + * @return string + * @throws \Exception + */ + public function getBranchRevFromRefLog(string $branch): string + { + $result = $this->runner->run( + (new BranchRevs($this->repo->getRoot()))->format('%h<--<|>-->%gs')->fromBranch($branch), + new Exploded('<--<|>-->', ['shortHash', 'subject']) + ); + $logs = $result->getFormattedOutput(); + foreach ($logs as $log) { + if (stripos($log['subject'], 'branch: Created from') !== false) { + return $log['shortHash']; + } + } + return ''; + } + + /** + * Get list of commits since given revision. + * + * @param string $revision + * @return array<\SebastianFeldmann\Git\Log\Commit> + * @throws \Exception + */ + public function getCommitsSince(string $revision): array + { + $cmd = (new Commits($this->repo->getRoot()))->byRange($revision)->prettyFormat(Commits\Xml::FORMAT); + $result = $this->runner->run($cmd); + return Xml::parseLogOutput($result->getStdOut()); + } + + /** + * Get list of commits between to given revisions + * + * @param string $from + * @param string $to + * @return array<\SebastianFeldmann\Git\Log\Commit> + * @throws \Exception + */ + public function getCommitsBetween(string $from, string $to): array + { + $cmd = (new Commits($this->repo->getRoot()))->byRange($from, $to)->prettyFormat(Commits\Xml::FORMAT); + $result = $this->runner->run($cmd); + return Xml::parseLogOutput($result->getStdOut()); + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Remote.php b/lib/sebastianfeldmann/git/src/Operator/Remote.php new file mode 100644 index 0000000000..4494991eba --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Remote.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use RuntimeException; +use SebastianFeldmann\Git\Command\Add\AddFiles; +use SebastianFeldmann\Git\Command\DiffIndex\GetStagedFiles; +use SebastianFeldmann\Git\Command\DiffIndex\GetStagedFiles\FilterByStatus; +use SebastianFeldmann\Git\Command\Fetch\Fetch; +use SebastianFeldmann\Git\Command\Pull\Pull; +use SebastianFeldmann\Git\Command\RevParse\GetCommitHash; +use SebastianFeldmann\Git\Command\Rm\RemoveFiles; +use SebastianFeldmann\Git\Diff\FilterUtil; + +/** + * Remote Operator + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.9.4 + */ +class Remote extends Base +{ + /** + * Fetch remote updates for a branch + * + * @param string $remote + * @param string $branch + * @return void + */ + public function fetchBranch(string $remote = 'origin', string $branch = ''): void + { + $cmd = (new Fetch($this->repo->getRoot()))->remote($remote)->branch($branch); + $this->runner->run($cmd); + } + + /** + * Fetch remote update and merge them into current branch + * + * @param string $remote + * @param string $branch + * @return void + */ + public function pullBranch(string $remote = 'origin', string $branch = ''): void + { + $cmd = (new Pull($this->repo->getRoot()))->remote($remote)->branch($branch); + $this->runner->run($cmd); + } +} diff --git a/lib/sebastianfeldmann/git/src/Operator/Status.php b/lib/sebastianfeldmann/git/src/Operator/Status.php new file mode 100644 index 0000000000..7a1713ec6f --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Operator/Status.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use SebastianFeldmann\Git\Command\Checkout\RestoreWorkingTree; +use SebastianFeldmann\Git\Command\Status\WorkingTreeStatus; +use SebastianFeldmann\Git\Command\Status\Porcelain\PathList; + +/** + * Class Status + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.6.0 + */ +class Status extends Base +{ + /** + * Returns a list of paths in the working tree and index, with statuses. + * + * @return \SebastianFeldmann\Git\Status\Path[] + */ + public function getWorkingTreeStatus(): iterable + { + $cmd = (new WorkingTreeStatus($this->repo->getRoot()))->ignoreSubmodules(); + + $result = $this->runner->run($cmd, new PathList()); + + return $result->getFormattedOutput(); + } + + /** + * Performs a checkout (restore) operation on the given paths + * (or the entire repo, by default). + * + * @param string[] $limitToPaths + * @return bool + */ + public function restoreWorkingTree(array $limitToPaths = ['.']): bool + { + $cmd = (new RestoreWorkingTree($this->repo->getRoot()))->skipHooks()->files($limitToPaths); + + $result = $this->runner->run($cmd); + + return $result->isSuccessful(); + } +} diff --git a/lib/sebastianfeldmann/git/src/Repository.php b/lib/sebastianfeldmann/git/src/Repository.php new file mode 100644 index 0000000000..63fc58f6b7 --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Repository.php @@ -0,0 +1,332 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git; + +use RuntimeException; +use SebastianFeldmann\Cli\Command\Runner; + +/** + * Class Repository + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 0.9.0 + */ +class Repository +{ + /** + * Path to git repository root + * + * @var string + */ + private string $root = ''; + + /** + * Path to .git directory + * + * @var string + */ + private string $dotGitDir = ''; + + /** + * Path to the configured hooks directory + * + * @var string + */ + private string $hooksDir = ''; + + /** + * Commit message. + * + * @var \SebastianFeldmann\Git\CommitMessage|null + */ + private ?CommitMessage $commitMsg = null; + + /** + * Executes cli commands + * + * @var \SebastianFeldmann\Cli\Command\Runner + */ + private Runner $runner; + + /** + * Map of operators + * + * @var array + */ + private array $operator = []; + + /** + * Repository constructor + * + * @param string $root + * @param \SebastianFeldmann\Cli\Command\Runner|null $runner + */ + public function __construct(string $root = '', ?Runner $runner = null) + { + $path = empty($root) ? getcwd() : $root; + $this->root = (string) $path; + $this->dotGitDir = $this->detectDotGitDir(); + $this->runner = null == $runner ? new Runner\Simple() : $runner; + } + + /** + * Detect the real .git directory because of submodules and multi worktree + * + * @return string + */ + private function detectDotGitDir(): string + { + $dotGitDir = $this->root . '/.git'; + if (self::isGitSubmodule($dotGitDir)) { + // For submodules hooks are stored in the parents .git/modules directory + $dotGitContents = (string) file_get_contents($dotGitDir); + $matches = []; + if (preg_match('/^gitdir:\s*(.+)$/m', $dotGitContents, $matches)) { + $dotGitDir = $this->root . '/' . $matches[1]; + } + } + return $dotGitDir; + } + + /** + * Root path getter + * + * @return string + */ + public function getRoot(): string + { + return $this->root; + } + + /** + * Returns the path to the hooks directory + * + * @return string + */ + public function getHooksDir(): string + { + if (empty($this->hooksDir)) { + $this->hooksDir = $this->detectHooksDir(); + } + return $this->hooksDir; + } + + /** + * Checks if the hooks dir is set to a custom path + * + * @return string + */ + private function detectHooksDir(): string + { + $hookPathConfig = $this->getConfigOperator()->getSettingSafely('core.hooksPath'); + return empty($hookPathConfig) ? $this->defaultHooksDir() : $this->customHooksDir($hookPathConfig); + } + + /** + * Returns the path to the default hook directory .git/hooks + * + * @return string + */ + private function defaultHooksDir(): string + { + return $this->dotGitDir . DIRECTORY_SEPARATOR . 'hooks'; + } + + /** + * Returns the path to the custom hook directory (might be absolute) + * + * @param string $hooksPath + * @return string + */ + private function customHooksDir(string $hooksPath): string + { + return substr($hooksPath, 0, 1) === DIRECTORY_SEPARATOR + ? $hooksPath + : $this->root . DIRECTORY_SEPARATOR . $hooksPath; + } + + /** + * Check for a hook file. + * + * @param string $hook + * @return bool + */ + public function hookExists(string $hook): bool + { + return file_exists($this->getHooksDir() . DIRECTORY_SEPARATOR . $hook); + } + + /** + * CommitMessage setter. + * + * @param \SebastianFeldmann\Git\CommitMessage $commitMsg + * @return void + */ + public function setCommitMsg(CommitMessage $commitMsg): void + { + $this->commitMsg = $commitMsg; + } + + /** + * CommitMessage getter. + * + * @return \SebastianFeldmann\Git\CommitMessage + */ + public function getCommitMsg(): CommitMessage + { + if (null === $this->commitMsg) { + throw new RuntimeException('No commit message available'); + } + return $this->commitMsg; + } + + /** + * Is there a merge in progress + * + * Will return true as soon as there are any MERGE_* files present in your .git directory. + * This is not only the case while merging but can also happen if you use `cherry-pick` + * without letting git instantly commit the picked changes. + * + * @return bool + */ + public function isMerging(): bool + { + foreach (['MERGE_MSG', 'MERGE_HEAD', 'MERGE_MODE'] as $fileName) { + if (file_exists($this->dotGitDir . DIRECTORY_SEPARATOR . $fileName)) { + return true; + } + } + return false; + } + + /** + * Get index operator. + * + * @return \SebastianFeldmann\Git\Operator\Index + */ + public function getIndexOperator(): Operator\Index + { + return $this->getOperator('Index'); + } + + /** + * Get info operator. + * + * @return \SebastianFeldmann\Git\Operator\Info + */ + public function getInfoOperator(): Operator\Info + { + return $this->getOperator('Info'); + } + + /** + * Get log operator. + * + * @return \SebastianFeldmann\Git\Operator\Log + */ + public function getLogOperator(): Operator\Log + { + return $this->getOperator('Log'); + } + + /** + * Get config operator. + * + * @return \SebastianFeldmann\Git\Operator\Config + */ + public function getConfigOperator(): Operator\Config + { + return $this->getOperator('Config'); + } + + /** + * Get diff operator + * + * Responsible for inspection and comparison commands + * + * @return \SebastianFeldmann\Git\Operator\Diff + */ + public function getDiffOperator(): Operator\Diff + { + return $this->getOperator('Diff'); + } + + /** + * Get status operator. + * + * @return \SebastianFeldmann\Git\Operator\Status + */ + public function getStatusOperator(): Operator\Status + { + return $this->getOperator('Status'); + } + + /** + * Get Remote operator. + * + * @return \SebastianFeldmann\Git\Operator\Remote + */ + public function getRemoteOperator(): Operator\Remote + { + return $this->getOperator('Remote'); + } + + /** + * Return requested operator. + * + * @param string $name + * @return mixed + */ + private function getOperator(string $name): mixed + { + if (!isset($this->operator[$name])) { + $class = substr(self::class, 0, -10) . 'Operator\\' . $name; + $this->operator[$name] = new $class($this->runner, $this); + } + return $this->operator[$name]; + } + + /** + * Creates a Repository but makes sure the repository exists + * + * @param string $root + * @param \SebastianFeldmann\Cli\Command\Runner|null $runner + * @return \SebastianFeldmann\Git\Repository + */ + public static function createVerified(string $root, ?Runner $runner = null): Repository + { + if (!self::isGitRepository($root)) { + throw new RuntimeException(sprintf('Invalid git repository: %s', $root)); + } + return new Repository($root, $runner); + } + + /** + * @param string $root + * @return bool + */ + public static function isGitRepository(string $root): bool + { + return is_dir($root . '/.git') || is_file($root . '/.git'); + } + + /** + * @param string $root + * @return bool + */ + public static function isGitSubmodule(string $root): bool + { + return !is_dir($root) && is_file($root); + } +} diff --git a/lib/sebastianfeldmann/git/src/Repository/Cloner.php b/lib/sebastianfeldmann/git/src/Repository/Cloner.php new file mode 100644 index 0000000000..94a67ccc7b --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Repository/Cloner.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace SebastianFeldmann\Git\Repository; + +use SebastianFeldmann\Cli\Command\Runner; +use SebastianFeldmann\Git\Command\CloneCmd\CloneCmd; +use SebastianFeldmann\Git\Repository; +use SebastianFeldmann\Git\Url; + +/** + * Class Cloner + * + * Responsible for all `git clone` operations + * + * @package SebastianFeldmann\Git + * @author Andreas Frömer + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.8.0 + */ +final class Cloner +{ + /** + * @var string + */ + private string $root; + + /** + * @var int + */ + private int $depth = 0; + + /** + * @var \SebastianFeldmann\Cli\Command\Runner + */ + private Runner $runner; + + /** + * Cloner constructor + * + * @param string $root + * @param Runner|null $runner + */ + public function __construct(string $root = '', ?Runner $runner = null) + { + $this->root = empty($root) ? (string) getcwd() : $root; + $this->runner = $runner ?? new Runner\Simple(); + } + + /** + * Set the `--depth` option + * + * @param int $depth + * @return $this + */ + public function depth(int $depth): Cloner + { + $this->depth = $depth; + return $this; + } + + /** + * Clone a given repository $url. + * + * @param string $url Url of repository + * @param string $dir The directory where the content should be cloned into. + * If this is an absolute path this directory will be used. + * If this is a relative path, and new folder will be created inside + * the current working directory. + */ + public function clone(string $url, string $dir = ''): Repository + { + $repositoryUrl = new Url($url); + $cloneCommand = new CloneCmd($repositoryUrl); + + if (empty($dir)) { + $dir = $this->root . '/' . $repositoryUrl->getRepoName(); + } + + $cloneCommand->dir($dir); + + if ($this->depth > 0) { + $cloneCommand->depth($this->depth); + } + + $this->runner->run($cloneCommand); + + return Repository::createVerified($dir, $this->runner); + } +} diff --git a/lib/sebastianfeldmann/git/src/Status/Path.php b/lib/sebastianfeldmann/git/src/Status/Path.php new file mode 100644 index 0000000000..a417c46a3d --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Status/Path.php @@ -0,0 +1,349 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Status; + +/** + * Class Path + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.6.0 + * @link https://git-scm.com/docs/git-status#_output git-status status codes + */ +class Path +{ + public const UNMODIFIED = "\x20"; + public const MODIFIED = 'M'; + public const ADDED = 'A'; + public const DELETED = 'D'; + public const RENAMED = 'R'; + public const COPIED = 'C'; + public const UPDATED_UNMERGED = 'U'; + public const UNTRACKED = '??'; + public const IGNORED = '!!'; + + /** + * Status code tuple. + * + * We initialize each item in the tuple with a single space (U+0020), + * since a space is a valid character, meaning "unmodified," in the + * `git status` output. + * + * @var array{0: string, 1: string} + */ + private array $statusCode = [self::UNMODIFIED, self::UNMODIFIED]; + + /** + * Path. + * + * @var string + */ + private string $path = ''; + + /** + * Original path, if this is a copied or renamed path. + * + * @var string|null + */ + private ?string $originalPath = null; + + /** + * Path constructor. + * + * @param string $statusCode + * @param string $path + * @param string|null $originalPath + */ + public function __construct(string $statusCode, string $path, ?string $originalPath = null) + { + $this->statusCode[0] = $statusCode[0] ?? self::UNMODIFIED; + $this->statusCode[1] = $statusCode[1] ?? self::UNMODIFIED; + $this->path = $path; + $this->originalPath = $originalPath; + } + + /** + * Returns the status code tuple. + * + * @return array{0: string, 1: string} + */ + public function getStatusCode(): array + { + return $this->statusCode; + } + + /** + * Returns the status code as it appears in the raw `git status` output. + * + * @return string + */ + public function getRawStatusCode(): string + { + return implode('', $this->statusCode); + } + + /** + * Returns the path. + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Returns the original path, if this is a copied or renamed path. + * + * @return string|null + */ + public function getOriginalPath(): ?string + { + return $this->originalPath; + } + + /** + * Returns true if the path is not updated in the index. + * + * @return bool + */ + public function isNotUpdated(): bool + { + return $this->getStatusCode()[0] === self::UNMODIFIED; + } + + /** + * Returns true if the path is updated in the index. + * + * @return bool + */ + public function isUpdatedInIndex(): bool + { + return $this->getStatusCode()[0] === self::MODIFIED; + } + + /** + * Returns true if the path is a new file added to the index. + * + * @return bool + */ + public function isAddedToIndex(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[0] === self::ADDED; + } + + /** + * Return true if the path is deleted from the index. + * + * @return bool + */ + public function isDeletedFromIndex(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[0] === self::DELETED; + } + + /** + * Returns true if the path is renamed in the index. + * + * @return bool + */ + public function isRenamedInIndex(): bool + { + return $this->getStatusCode()[0] === self::RENAMED; + } + + /** + * Returns true if the path is copied in the index. + * + * @return bool + */ + public function isCopiedInIndex(): bool + { + return $this->getStatusCode()[0] === self::COPIED; + } + + /** + * Returns true if the path in the index matches that in the working tree. + * + * @return bool + */ + public function doesIndexMatchWorkingTree(): bool + { + return $this->getStatusCode()[1] === self::UNMODIFIED; + } + + /** + * Returns true if the path in the working tree has changes + * that are not in the index. + * + * @return bool + */ + public function hasWorkingTreeChangedSinceIndex(): bool + { + return $this->getStatusCode()[1] === self::MODIFIED; + } + + /** + * Returns true if the path is deleted in the working tree + * but not in the index. + * + * @return bool + */ + public function isDeletedInWorkingTree(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[1] === self::DELETED; + } + + /** + * Returns true if the path is renamed in the working tree + * but not in the index. + * + * @return bool + */ + public function isRenamedInWorkingTree(): bool + { + return $this->getStatusCode()[1] === self::RENAMED; + } + + /** + * Returns true if the path is copied in the working tree + * but not in the index. + * + * @return bool + */ + public function isCopiedInWorkingTree(): bool + { + return $this->getStatusCode()[1] === self::COPIED; + } + + /** + * Returns true if the path is added in the working tree + * but not in the index (a.k.a. intent to add). + * + * @return bool + */ + public function isAddedInWorkingTree(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[1] === self::ADDED; + } + + /** + * Returns true if there is currently a merge conflict + * and the path needs conflicts resolved. + * + * @return bool + */ + public function isUnmerged(): bool + { + return in_array(self::UPDATED_UNMERGED, $this->getStatusCode()) + || $this->areBothDeleted() + || $this->areBothAdded(); + } + + /** + * Returns true if the path is in conflict and deleted by each head of the merge. + * + * @return bool + */ + public function areBothDeleted(): bool + { + return $this->getStatusCode()[0] === self::DELETED + && $this->getStatusCode()[1] === self::DELETED; + } + + /** + * Returns true if the path is in conflict and added by each head of the merge. + * + * @return bool + */ + public function areBothAdded(): bool + { + return $this->getStatusCode()[0] === self::ADDED + && $this->getStatusCode()[1] === self::ADDED; + } + + /** + * Returns true if the path is in conflict and modified by each head of the merge. + * + * @return bool + */ + public function areBothModified(): bool + { + return $this->getStatusCode()[0] === self::UPDATED_UNMERGED + && $this->getStatusCode()[1] === self::UPDATED_UNMERGED; + } + + /** + * Returns true if the path is in conflict and added by us. + * + * @return bool + */ + public function isAddedByUs(): bool + { + return $this->getStatusCode()[0] === self::ADDED + && $this->getStatusCode()[1] === self::UPDATED_UNMERGED; + } + + /** + * Returns true if the path is in conflict and deleted by us. + * + * @return bool + */ + public function isDeletedByUs(): bool + { + return $this->getStatusCode()[0] === self::DELETED + && $this->getStatusCode()[1] === self::UPDATED_UNMERGED; + } + + /** + * Returns true if the path is in conflict and added by them. + * + * @return bool + */ + public function isAddedByThem(): bool + { + return $this->getStatusCode()[0] === self::UPDATED_UNMERGED + && $this->getStatusCode()[1] === self::ADDED; + } + + /** + * Returns true if the path is in conflict and deleted by them. + * + * @return bool + */ + public function isDeletedByThem(): bool + { + return $this->getStatusCode()[0] === self::UPDATED_UNMERGED + && $this->getStatusCode()[1] === self::DELETED; + } + + /** + * Returns true if the path is untracked. + * + * @return bool + */ + public function isUntracked(): bool + { + return $this->getRawStatusCode() === self::UNTRACKED; + } + + /** + * Returns true if the path is ignored. + * + * @return bool + */ + public function isIgnored(): bool + { + return $this->getRawStatusCode() === self::IGNORED; + } +} diff --git a/lib/sebastianfeldmann/git/src/Url.php b/lib/sebastianfeldmann/git/src/Url.php new file mode 100644 index 0000000000..d6f8e99cff --- /dev/null +++ b/lib/sebastianfeldmann/git/src/Url.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace SebastianFeldmann\Git; + +use RuntimeException; + +/** + * Class Url + * + * Represents a valid repository URL either http or ssh. + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release 3.8.0 + */ +final class Url +{ + /** + * @var string + */ + private string $url; + + /** + * @var string + */ + private string $scheme; + + /** + * @var string + */ + private string $user; + + /** + * @var string + */ + private string $host; + + /** + * @var string + */ + private string $path; + + /** + * @var string + */ + private string $repoName; + + public function __construct(string $url) + { + $parsed = $this->parseUrl($url); + $this->url = $url; + $this->scheme = (string) ($parsed['scheme'] ?? ''); + $this->user = (string) ($parsed['user'] ?? ''); + $this->host = (string) ($parsed['host'] ?? ''); + $this->path = (string) ($parsed['path'] ?? ''); + $this->repoName = $this->parseRepoName($this->path); + } + + /** + * Is the given url an SSH clone URL + * + * @param string $url + * @return bool + */ + public static function isSSHUrl(string $url): bool + { + // should not contain http + // should at least contain one colon + return !str_contains($url, 'http') && str_contains($url, ':'); + } + + /** + * Returns the full url + * + * @return string + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * Returns only the scheme + * + * @return string + */ + public function getScheme(): string + { + return $this->scheme; + } + + /** + * Returns only the user + * + * @return string + */ + public function getUser(): string + { + return $this->user; + } + + /** + * Returns only the host + * + * @return string + */ + public function getHost(): string + { + return $this->host; + } + + /** + * Returns only the path + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Returns the repo name (last path segment of the url) + * + * @return string + */ + public function getRepoName(): string + { + return $this->repoName; + } + + /** + * Detect the url components + * + * @param string $url + * @return array + */ + private function parseUrl(string $url): array + { + // By default, GitHub and gitlab urls can't be parsed by parse_url, + // so we have to make sure we end up with parsable urls + if (self::isSSHUrl($url)) { + $url = $this->convertToValidUrl($url); + } + + $parsed = parse_url($url); + + if (!is_array($parsed)) { + throw new RuntimeException('can\'t parse repository url'); + } + return $parsed; + } + + /** + * This converts GitHub and gitlab ssh urls to parsable urls + * + * @param string $url + * @return string + */ + private function convertToValidUrl(string $url): string + { + $url = $this->addMissingScheme($url); + return $this->replaceColonWithSlash($url); + } + + /** + * Find the repo name within the url path + * + * @param string $path + * @return string + */ + private function parseRepoName(string $path): string + { + $lastSlashPosition = strrpos($path, '/'); + return str_replace('.git', '', substr($path, $lastSlashPosition + 1)); + } + + /** + * This will add the ssh scheme if it is missing + * + * @param string $url + * @return string + */ + private function addMissingScheme(string $url): string + { + return str_contains($url, 'ssh://') ? $url : 'ssh://' . $url; + } + + /** + * Replace the git@github.com:user/repo with github.com/user/repo + * + * @param string $url + * @return string + */ + private function replaceColonWithSlash(string $url): string + { + $lastColonPosition = (int)strrpos($url, ':'); + return substr($url, 0, $lastColonPosition) . '/' . substr($url, $lastColonPosition + 1); + } +} diff --git a/lib/symfony/process/CHANGELOG.md b/lib/symfony/process/CHANGELOG.md new file mode 100644 index 0000000000..dc0a0cc5b1 --- /dev/null +++ b/lib/symfony/process/CHANGELOG.md @@ -0,0 +1,123 @@ +CHANGELOG +========= + +6.4 +--- + + * Add `PhpSubprocess` to handle PHP subprocesses that take over the + configuration from their parent + * Add `RunProcessMessage` and `RunProcessMessageHandler` + +5.2.0 +----- + + * added `Process::setOptions()` to set `Process` specific options + * added option `create_new_console` to allow a subprocess to continue + to run after the main script exited, both on Linux and on Windows + +5.1.0 +----- + + * added `Process::getStartTime()` to retrieve the start time of the process as float + +5.0.0 +----- + + * removed `Process::inheritEnvironmentVariables()` + * removed `PhpProcess::setPhpBinary()` + * `Process` must be instantiated with a command array, use `Process::fromShellCommandline()` when the command should be parsed by the shell + * removed `Process::setCommandLine()` + +4.4.0 +----- + + * deprecated `Process::inheritEnvironmentVariables()`: env variables are always inherited. + * added `Process::getLastOutputTime()` method + +4.2.0 +----- + + * added the `Process::fromShellCommandline()` to run commands in a shell wrapper + * deprecated passing a command as string when creating a `Process` instance + * deprecated the `Process::setCommandline()` and the `PhpProcess::setPhpBinary()` methods + * added the `Process::waitUntil()` method to wait for the process only for a + specific output, then continue the normal execution of your application + +4.1.0 +----- + + * added the `Process::isTtySupported()` method that allows to check for TTY support + * made `PhpExecutableFinder` look for the `PHP_BINARY` env var when searching the php binary + * added the `ProcessSignaledException` class to properly catch signaled process errors + +4.0.0 +----- + + * environment variables will always be inherited + * added a second `array $env = []` argument to the `start()`, `run()`, + `mustRun()`, and `restart()` methods of the `Process` class + * added a second `array $env = []` argument to the `start()` method of the + `PhpProcess` class + * the `ProcessUtils::escapeArgument()` method has been removed + * the `areEnvironmentVariablesInherited()`, `getOptions()`, and `setOptions()` + methods of the `Process` class have been removed + * support for passing `proc_open()` options has been removed + * removed the `ProcessBuilder` class, use the `Process` class instead + * removed the `getEnhanceWindowsCompatibility()` and `setEnhanceWindowsCompatibility()` methods of the `Process` class + * passing a not existing working directory to the constructor of the `Symfony\Component\Process\Process` class is not + supported anymore + +3.4.0 +----- + + * deprecated the ProcessBuilder class + * deprecated calling `Process::start()` without setting a valid working directory beforehand (via `setWorkingDirectory()` or constructor) + +3.3.0 +----- + + * added command line arrays in the `Process` class + * added `$env` argument to `Process::start()`, `run()`, `mustRun()` and `restart()` methods + * deprecated the `ProcessUtils::escapeArgument()` method + * deprecated not inheriting environment variables + * deprecated configuring `proc_open()` options + * deprecated configuring enhanced Windows compatibility + * deprecated configuring enhanced sigchild compatibility + +2.5.0 +----- + + * added support for PTY mode + * added the convenience method "mustRun" + * deprecation: Process::setStdin() is deprecated in favor of Process::setInput() + * deprecation: Process::getStdin() is deprecated in favor of Process::getInput() + * deprecation: Process::setInput() and ProcessBuilder::setInput() do not accept non-scalar types + +2.4.0 +----- + + * added the ability to define an idle timeout + +2.3.0 +----- + + * added ProcessUtils::escapeArgument() to fix the bug in escapeshellarg() function on Windows + * added Process::signal() + * added Process::getPid() + * added support for a TTY mode + +2.2.0 +----- + + * added ProcessBuilder::setArguments() to reset the arguments on a builder + * added a way to retrieve the standard and error output incrementally + * added Process:restart() + +2.1.0 +----- + + * added support for non-blocking processes (start(), wait(), isRunning(), stop()) + * enhanced Windows compatibility + * added Process::getExitCodeText() that returns a string representation for + the exit code returned by the process + * added ProcessBuilder diff --git a/lib/symfony/process/Exception/ExceptionInterface.php b/lib/symfony/process/Exception/ExceptionInterface.php new file mode 100644 index 0000000000..bd4a60403b --- /dev/null +++ b/lib/symfony/process/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +/** + * Marker Interface for the Process Component. + * + * @author Johannes M. Schmitt + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/lib/symfony/process/Exception/InvalidArgumentException.php b/lib/symfony/process/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000..926ee2118b --- /dev/null +++ b/lib/symfony/process/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +/** + * InvalidArgumentException for the Process Component. + * + * @author Romain Neutron + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/lib/symfony/process/Exception/LogicException.php b/lib/symfony/process/Exception/LogicException.php new file mode 100644 index 0000000000..be3d490dde --- /dev/null +++ b/lib/symfony/process/Exception/LogicException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +/** + * LogicException for the Process Component. + * + * @author Romain Neutron + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/lib/symfony/process/Exception/ProcessFailedException.php b/lib/symfony/process/Exception/ProcessFailedException.php new file mode 100644 index 0000000000..29cd386bbb --- /dev/null +++ b/lib/symfony/process/Exception/ProcessFailedException.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Process; + +/** + * Exception for failed processes. + * + * @author Johannes M. Schmitt + */ +class ProcessFailedException extends RuntimeException +{ + private Process $process; + + public function __construct(Process $process) + { + if ($process->isSuccessful()) { + throw new InvalidArgumentException('Expected a failed process, but the given process was successful.'); + } + + $error = \sprintf('The command "%s" failed.'."\n\nExit Code: %s(%s)\n\nWorking directory: %s", + $process->getCommandLine(), + $process->getExitCode(), + $process->getExitCodeText(), + $process->getWorkingDirectory() + ); + + if (!$process->isOutputDisabled()) { + $error .= \sprintf("\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s", + $process->getOutput(), + $process->getErrorOutput() + ); + } + + parent::__construct($error); + + $this->process = $process; + } + + /** + * @return Process + */ + public function getProcess() + { + return $this->process; + } +} diff --git a/lib/symfony/process/Exception/ProcessSignaledException.php b/lib/symfony/process/Exception/ProcessSignaledException.php new file mode 100644 index 0000000000..12eb4b3b86 --- /dev/null +++ b/lib/symfony/process/Exception/ProcessSignaledException.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Process; + +/** + * Exception that is thrown when a process has been signaled. + * + * @author Sullivan Senechal + */ +final class ProcessSignaledException extends RuntimeException +{ + private Process $process; + + public function __construct(Process $process) + { + $this->process = $process; + + parent::__construct(\sprintf('The process has been signaled with signal "%s".', $process->getTermSignal())); + } + + public function getProcess(): Process + { + return $this->process; + } + + public function getSignal(): int + { + return $this->getProcess()->getTermSignal(); + } +} diff --git a/lib/symfony/process/Exception/ProcessTimedOutException.php b/lib/symfony/process/Exception/ProcessTimedOutException.php new file mode 100644 index 0000000000..94c1a33af6 --- /dev/null +++ b/lib/symfony/process/Exception/ProcessTimedOutException.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Process; + +/** + * Exception that is thrown when a process times out. + * + * @author Johannes M. Schmitt + */ +class ProcessTimedOutException extends RuntimeException +{ + public const TYPE_GENERAL = 1; + public const TYPE_IDLE = 2; + + private Process $process; + private int $timeoutType; + + public function __construct(Process $process, int $timeoutType) + { + $this->process = $process; + $this->timeoutType = $timeoutType; + + parent::__construct(\sprintf( + 'The process "%s" exceeded the timeout of %s seconds.', + $process->getCommandLine(), + $this->getExceededTimeout() + )); + } + + /** + * @return Process + */ + public function getProcess() + { + return $this->process; + } + + /** + * @return bool + */ + public function isGeneralTimeout() + { + return self::TYPE_GENERAL === $this->timeoutType; + } + + /** + * @return bool + */ + public function isIdleTimeout() + { + return self::TYPE_IDLE === $this->timeoutType; + } + + public function getExceededTimeout(): ?float + { + return match ($this->timeoutType) { + self::TYPE_GENERAL => $this->process->getTimeout(), + self::TYPE_IDLE => $this->process->getIdleTimeout(), + default => throw new \LogicException(\sprintf('Unknown timeout type "%d".', $this->timeoutType)), + }; + } +} diff --git a/lib/symfony/process/Exception/RunProcessFailedException.php b/lib/symfony/process/Exception/RunProcessFailedException.php new file mode 100644 index 0000000000..e7219d351e --- /dev/null +++ b/lib/symfony/process/Exception/RunProcessFailedException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Messenger\RunProcessContext; + +/** + * @author Kevin Bond + */ +final class RunProcessFailedException extends RuntimeException +{ + public function __construct(ProcessFailedException $exception, public readonly RunProcessContext $context) + { + parent::__construct($exception->getMessage(), $exception->getCode()); + } +} diff --git a/lib/symfony/process/Exception/RuntimeException.php b/lib/symfony/process/Exception/RuntimeException.php new file mode 100644 index 0000000000..adead2536b --- /dev/null +++ b/lib/symfony/process/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +/** + * RuntimeException for the Process Component. + * + * @author Johannes M. Schmitt + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/lib/symfony/process/ExecutableFinder.php b/lib/symfony/process/ExecutableFinder.php new file mode 100644 index 0000000000..4d82026219 --- /dev/null +++ b/lib/symfony/process/ExecutableFinder.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +/** + * Generic executable finder. + * + * @author Fabien Potencier + * @author Johannes M. Schmitt + */ +class ExecutableFinder +{ + private const CMD_BUILTINS = [ + 'assoc', 'break', 'call', 'cd', 'chdir', 'cls', 'color', 'copy', 'date', + 'del', 'dir', 'echo', 'endlocal', 'erase', 'exit', 'for', 'ftype', 'goto', + 'help', 'if', 'label', 'md', 'mkdir', 'mklink', 'move', 'path', 'pause', + 'popd', 'prompt', 'pushd', 'rd', 'rem', 'ren', 'rename', 'rmdir', 'set', + 'setlocal', 'shift', 'start', 'time', 'title', 'type', 'ver', 'vol', + ]; + + private array $suffixes = []; + + /** + * Replaces default suffixes of executable. + * + * @return void + */ + public function setSuffixes(array $suffixes) + { + $this->suffixes = $suffixes; + } + + /** + * Adds new possible suffix to check for executable. + * + * @return void + */ + public function addSuffix(string $suffix) + { + $this->suffixes[] = $suffix; + } + + /** + * Finds an executable by name. + * + * @param string $name The executable name (without the extension) + * @param string|null $default The default to return if no executable is found + * @param array $extraDirs Additional dirs to check into + */ + public function find(string $name, ?string $default = null, array $extraDirs = []): ?string + { + // windows built-in commands that are present in cmd.exe should not be resolved using PATH as they do not exist as exes + if ('\\' === \DIRECTORY_SEPARATOR && \in_array(strtolower($name), self::CMD_BUILTINS, true)) { + return $name; + } + + $dirs = array_merge( + explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), + $extraDirs + ); + + $suffixes = []; + if ('\\' === \DIRECTORY_SEPARATOR) { + $pathExt = getenv('PATHEXT'); + $suffixes = $this->suffixes; + $suffixes = array_merge($suffixes, $pathExt ? explode(\PATH_SEPARATOR, $pathExt) : ['.exe', '.bat', '.cmd', '.com']); + } + $suffixes = '' !== pathinfo($name, \PATHINFO_EXTENSION) ? array_merge([''], $suffixes) : array_merge($suffixes, ['']); + foreach ($suffixes as $suffix) { + foreach ($dirs as $dir) { + if ('' === $dir) { + $dir = '.'; + } + if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($file))) { + return $file; + } + + if (!@is_dir($dir) && basename($dir) === $name.$suffix && @is_executable($dir)) { + return $dir; + } + } + } + + if ('\\' === \DIRECTORY_SEPARATOR || !\function_exists('exec') || \strlen($name) !== strcspn($name, '/'.\DIRECTORY_SEPARATOR)) { + return $default; + } + + $execResult = exec('command -v -- '.escapeshellarg($name)); + + if (($executablePath = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) && @is_executable($executablePath)) { + return $executablePath; + } + + return $default; + } +} diff --git a/lib/symfony/process/InputStream.php b/lib/symfony/process/InputStream.php new file mode 100644 index 0000000000..3bcbfe84dc --- /dev/null +++ b/lib/symfony/process/InputStream.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +use Symfony\Component\Process\Exception\RuntimeException; + +/** + * Provides a way to continuously write to the input of a Process until the InputStream is closed. + * + * @author Nicolas Grekas + * + * @implements \IteratorAggregate + */ +class InputStream implements \IteratorAggregate +{ + private ?\Closure $onEmpty = null; + private array $input = []; + private bool $open = true; + + /** + * Sets a callback that is called when the write buffer becomes empty. + * + * @return void + */ + public function onEmpty(?callable $onEmpty = null) + { + $this->onEmpty = null !== $onEmpty ? $onEmpty(...) : null; + } + + /** + * Appends an input to the write buffer. + * + * @param resource|string|int|float|bool|\Traversable|null $input The input to append as scalar, + * stream resource or \Traversable + * + * @return void + */ + public function write(mixed $input) + { + if (null === $input) { + return; + } + if ($this->isClosed()) { + throw new RuntimeException(\sprintf('"%s" is closed.', static::class)); + } + $this->input[] = ProcessUtils::validateInput(__METHOD__, $input); + } + + /** + * Closes the write buffer. + * + * @return void + */ + public function close() + { + $this->open = false; + } + + /** + * Tells whether the write buffer is closed or not. + * + * @return bool + */ + public function isClosed() + { + return !$this->open; + } + + public function getIterator(): \Traversable + { + $this->open = true; + + while ($this->open || $this->input) { + if (!$this->input) { + yield ''; + continue; + } + $current = array_shift($this->input); + + if ($current instanceof \Iterator) { + yield from $current; + } else { + yield $current; + } + if (!$this->input && $this->open && null !== $onEmpty = $this->onEmpty) { + $this->write($onEmpty($this)); + } + } + } +} diff --git a/lib/symfony/process/LICENSE b/lib/symfony/process/LICENSE new file mode 100644 index 0000000000..0138f8f071 --- /dev/null +++ b/lib/symfony/process/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-present Fabien Potencier + +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/lib/symfony/process/Messenger/RunProcessContext.php b/lib/symfony/process/Messenger/RunProcessContext.php new file mode 100644 index 0000000000..b5ade07223 --- /dev/null +++ b/lib/symfony/process/Messenger/RunProcessContext.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Messenger; + +use Symfony\Component\Process\Process; + +/** + * @author Kevin Bond + */ +final class RunProcessContext +{ + public readonly ?int $exitCode; + public readonly ?string $output; + public readonly ?string $errorOutput; + + public function __construct( + public readonly RunProcessMessage $message, + Process $process, + ) { + $this->exitCode = $process->getExitCode(); + $this->output = $process->isOutputDisabled() ? null : $process->getOutput(); + $this->errorOutput = $process->isOutputDisabled() ? null : $process->getErrorOutput(); + } +} diff --git a/lib/symfony/process/Messenger/RunProcessMessage.php b/lib/symfony/process/Messenger/RunProcessMessage.php new file mode 100644 index 0000000000..b2c33fe3b3 --- /dev/null +++ b/lib/symfony/process/Messenger/RunProcessMessage.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Messenger; + +/** + * @author Kevin Bond + */ +class RunProcessMessage implements \Stringable +{ + public function __construct( + public readonly array $command, + public readonly ?string $cwd = null, + public readonly ?array $env = null, + public readonly mixed $input = null, + public readonly ?float $timeout = 60.0, + ) { + } + + public function __toString(): string + { + return implode(' ', $this->command); + } +} diff --git a/lib/symfony/process/Messenger/RunProcessMessageHandler.php b/lib/symfony/process/Messenger/RunProcessMessageHandler.php new file mode 100644 index 0000000000..41c1934cc0 --- /dev/null +++ b/lib/symfony/process/Messenger/RunProcessMessageHandler.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Messenger; + +use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Exception\RunProcessFailedException; +use Symfony\Component\Process\Process; + +/** + * @author Kevin Bond + */ +final class RunProcessMessageHandler +{ + public function __invoke(RunProcessMessage $message): RunProcessContext + { + $process = new Process($message->command, $message->cwd, $message->env, $message->input, $message->timeout); + + try { + return new RunProcessContext($message, $process->mustRun()); + } catch (ProcessFailedException $e) { + throw new RunProcessFailedException($e, new RunProcessContext($message, $e->getProcess())); + } + } +} diff --git a/lib/symfony/process/PhpExecutableFinder.php b/lib/symfony/process/PhpExecutableFinder.php new file mode 100644 index 0000000000..e24ca008dd --- /dev/null +++ b/lib/symfony/process/PhpExecutableFinder.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +/** + * An executable finder specifically designed for the PHP executable. + * + * @author Fabien Potencier + * @author Johannes M. Schmitt + */ +class PhpExecutableFinder +{ + private ExecutableFinder $executableFinder; + + public function __construct() + { + $this->executableFinder = new ExecutableFinder(); + } + + /** + * Finds The PHP executable. + */ + public function find(bool $includeArgs = true): string|false + { + if ($php = getenv('PHP_BINARY')) { + if (!is_executable($php) && !$php = $this->executableFinder->find($php)) { + return false; + } + + if (@is_dir($php)) { + return false; + } + + return $php; + } + + $args = $this->findArguments(); + $args = $includeArgs && $args ? ' '.implode(' ', $args) : ''; + + // PHP_BINARY return the current sapi executable + if (\PHP_BINARY && \in_array(\PHP_SAPI, ['cli', 'cli-server', 'phpdbg'], true)) { + return \PHP_BINARY.$args; + } + + if ($php = getenv('PHP_PATH')) { + if (!@is_executable($php) || @is_dir($php)) { + return false; + } + + return $php; + } + + if ($php = getenv('PHP_PEAR_PHP_BIN')) { + if (@is_executable($php) && !@is_dir($php)) { + return $php; + } + } + + if (@is_executable($php = \PHP_BINDIR.('\\' === \DIRECTORY_SEPARATOR ? '\\php.exe' : '/php')) && !@is_dir($php)) { + return $php; + } + + $dirs = [\PHP_BINDIR]; + if ('\\' === \DIRECTORY_SEPARATOR) { + $dirs[] = 'C:\xampp\php\\'; + } + + return $this->executableFinder->find('php', false, $dirs); + } + + /** + * Finds the PHP executable arguments. + */ + public function findArguments(): array + { + $arguments = []; + if ('phpdbg' === \PHP_SAPI) { + $arguments[] = '-qrr'; + } + + return $arguments; + } +} diff --git a/lib/symfony/process/PhpProcess.php b/lib/symfony/process/PhpProcess.php new file mode 100644 index 0000000000..db6ebf2a2b --- /dev/null +++ b/lib/symfony/process/PhpProcess.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +use Symfony\Component\Process\Exception\LogicException; +use Symfony\Component\Process\Exception\RuntimeException; + +/** + * PhpProcess runs a PHP script in an independent process. + * + * $p = new PhpProcess(''); + * $p->run(); + * print $p->getOutput()."\n"; + * + * @author Fabien Potencier + */ +class PhpProcess extends Process +{ + /** + * @param string $script The PHP script to run (as a string) + * @param string|null $cwd The working directory or null to use the working dir of the current PHP process + * @param array|null $env The environment variables or null to use the same environment as the current PHP process + * @param int $timeout The timeout in seconds + * @param array|null $php Path to the PHP binary to use with any additional arguments + */ + public function __construct(string $script, ?string $cwd = null, ?array $env = null, int $timeout = 60, ?array $php = null) + { + if (null === $php) { + $executableFinder = new PhpExecutableFinder(); + $php = $executableFinder->find(false); + $php = false === $php ? null : array_merge([$php], $executableFinder->findArguments()); + } + if ('phpdbg' === \PHP_SAPI) { + $file = tempnam(sys_get_temp_dir(), 'dbg'); + file_put_contents($file, $script); + register_shutdown_function('unlink', $file); + $php[] = $file; + $script = null; + } + + parent::__construct($php, $cwd, $env, $script, $timeout); + } + + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static + { + throw new LogicException(\sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); + } + + /** + * @return void + */ + public function start(?callable $callback = null, array $env = []) + { + if (null === $this->getCommandLine()) { + throw new RuntimeException('Unable to find the PHP executable.'); + } + + parent::start($callback, $env); + } +} diff --git a/lib/symfony/process/PhpSubprocess.php b/lib/symfony/process/PhpSubprocess.php new file mode 100644 index 0000000000..bdd4173c2a --- /dev/null +++ b/lib/symfony/process/PhpSubprocess.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +use Symfony\Component\Process\Exception\LogicException; +use Symfony\Component\Process\Exception\RuntimeException; + +/** + * PhpSubprocess runs a PHP command as a subprocess while keeping the original php.ini settings. + * + * For this, it generates a temporary php.ini file taking over all the current settings and disables + * loading additional .ini files. Basically, your command gets prefixed using "php -n -c /tmp/temp.ini". + * + * Given your php.ini contains "memory_limit=-1" and you have a "MemoryTest.php" with the following content: + * + * run(); + * print $p->getOutput()."\n"; + * + * This will output "string(2) "-1", because the process is started with the default php.ini settings. + * + * $p = new PhpSubprocess(['MemoryTest.php'], null, null, 60, ['php', '-d', 'memory_limit=256M']); + * $p->run(); + * print $p->getOutput()."\n"; + * + * This will output "string(4) "256M"", because the process is started with the temporarily created php.ini settings. + * + * @author Yanick Witschi + * @author Partially copied and heavily inspired from composer/xdebug-handler by John Stevenson + */ +class PhpSubprocess extends Process +{ + /** + * @param array $command The command to run and its arguments listed as separate entries. They will automatically + * get prefixed with the PHP binary + * @param string|null $cwd The working directory or null to use the working dir of the current PHP process + * @param array|null $env The environment variables or null to use the same environment as the current PHP process + * @param int $timeout The timeout in seconds + * @param array|null $php Path to the PHP binary to use with any additional arguments + */ + public function __construct(array $command, ?string $cwd = null, ?array $env = null, int $timeout = 60, ?array $php = null) + { + if (null === $php) { + $executableFinder = new PhpExecutableFinder(); + $php = $executableFinder->find(false); + $php = false === $php ? null : array_merge([$php], $executableFinder->findArguments()); + } + + if (null === $php) { + throw new RuntimeException('Unable to find PHP binary.'); + } + + $tmpIni = $this->writeTmpIni($this->getAllIniFiles(), sys_get_temp_dir()); + + $php = array_merge($php, ['-n', '-c', $tmpIni]); + register_shutdown_function('unlink', $tmpIni); + + $command = array_merge($php, $command); + + parent::__construct($command, $cwd, $env, null, $timeout); + } + + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static + { + throw new LogicException(\sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); + } + + public function start(?callable $callback = null, array $env = []): void + { + if (null === $this->getCommandLine()) { + throw new RuntimeException('Unable to find the PHP executable.'); + } + + parent::start($callback, $env); + } + + private function writeTmpIni(array $iniFiles, string $tmpDir): string + { + if (false === $tmpfile = @tempnam($tmpDir, '')) { + throw new RuntimeException('Unable to create temporary ini file.'); + } + + // $iniFiles has at least one item and it may be empty + if ('' === $iniFiles[0]) { + array_shift($iniFiles); + } + + $content = ''; + + foreach ($iniFiles as $file) { + // Check for inaccessible ini files + if (($data = @file_get_contents($file)) === false) { + throw new RuntimeException('Unable to read ini: '.$file); + } + // Check and remove directives after HOST and PATH sections + if (preg_match('/^\s*\[(?:PATH|HOST)\s*=/mi', $data, $matches, \PREG_OFFSET_CAPTURE)) { + $data = substr($data, 0, $matches[0][1]); + } + + $content .= $data."\n"; + } + + // Merge loaded settings into our ini content, if it is valid + $config = parse_ini_string($content); + $loaded = ini_get_all(null, false); + + if (false === $config || false === $loaded) { + throw new RuntimeException('Unable to parse ini data.'); + } + + $content .= $this->mergeLoadedConfig($loaded, $config); + + // Work-around for https://bugs.php.net/bug.php?id=75932 + $content .= "opcache.enable_cli=0\n"; + + if (false === @file_put_contents($tmpfile, $content)) { + throw new RuntimeException('Unable to write temporary ini file.'); + } + + return $tmpfile; + } + + private function mergeLoadedConfig(array $loadedConfig, array $iniConfig): string + { + $content = ''; + + foreach ($loadedConfig as $name => $value) { + if (!\is_string($value)) { + continue; + } + + if (!isset($iniConfig[$name]) || $iniConfig[$name] !== $value) { + // Double-quote escape each value + $content .= $name.'="'.addcslashes($value, '\\"')."\"\n"; + } + } + + return $content; + } + + private function getAllIniFiles(): array + { + $paths = [(string) php_ini_loaded_file()]; + + if (false !== $scanned = php_ini_scanned_files()) { + $paths = array_merge($paths, array_map('trim', explode(',', $scanned))); + } + + return $paths; + } +} diff --git a/lib/symfony/process/Pipes/AbstractPipes.php b/lib/symfony/process/Pipes/AbstractPipes.php new file mode 100644 index 0000000000..158f0487f9 --- /dev/null +++ b/lib/symfony/process/Pipes/AbstractPipes.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Pipes; + +use Symfony\Component\Process\Exception\InvalidArgumentException; + +/** + * @author Romain Neutron + * + * @internal + */ +abstract class AbstractPipes implements PipesInterface +{ + public array $pipes = []; + + private string $inputBuffer = ''; + /** @var resource|string|\Iterator */ + private $input; + private bool $blocked = true; + private ?string $lastError = null; + + /** + * @param resource|string|\Iterator $input + */ + public function __construct($input) + { + if (\is_resource($input) || $input instanceof \Iterator) { + $this->input = $input; + } else { + $this->inputBuffer = (string) $input; + } + } + + public function close(): void + { + foreach ($this->pipes as $pipe) { + if (\is_resource($pipe)) { + fclose($pipe); + } + } + $this->pipes = []; + } + + /** + * Returns true if a system call has been interrupted. + * + * stream_select() returns false when the `select` system call is interrupted by an incoming signal. + */ + protected function hasSystemCallBeenInterrupted(): bool + { + $lastError = $this->lastError; + $this->lastError = null; + + if (null === $lastError) { + return false; + } + + if (false !== stripos($lastError, 'interrupted system call')) { + return true; + } + + // on applications with a different locale than english, the message above is not found because + // it's translated. So we also check for the SOCKET_EINTR constant which is defined under + // Windows and UNIX-like platforms (if available on the platform). + return \defined('SOCKET_EINTR') && str_starts_with($lastError, 'stream_select(): Unable to select ['.\SOCKET_EINTR.']'); + } + + /** + * Unblocks streams. + */ + protected function unblock(): void + { + if (!$this->blocked) { + return; + } + + foreach ($this->pipes as $pipe) { + stream_set_blocking($pipe, false); + } + if (\is_resource($this->input)) { + stream_set_blocking($this->input, false); + } + + $this->blocked = false; + } + + /** + * Writes input to stdin. + * + * @throws InvalidArgumentException When an input iterator yields a non supported value + */ + protected function write(): ?array + { + if (!isset($this->pipes[0])) { + return null; + } + $input = $this->input; + + if ($input instanceof \Iterator) { + if (!$input->valid()) { + $input = null; + } elseif (\is_resource($input = $input->current())) { + stream_set_blocking($input, false); + } elseif (!isset($this->inputBuffer[0])) { + if (!\is_string($input)) { + if (!\is_scalar($input)) { + throw new InvalidArgumentException(\sprintf('"%s" yielded a value of type "%s", but only scalars and stream resources are supported.', get_debug_type($this->input), get_debug_type($input))); + } + $input = (string) $input; + } + $this->inputBuffer = $input; + $this->input->next(); + $input = null; + } else { + $input = null; + } + } + + $r = $e = []; + $w = [$this->pipes[0]]; + + // let's have a look if something changed in streams + if (false === @stream_select($r, $w, $e, 0, 0)) { + return null; + } + + foreach ($w as $stdin) { + if (isset($this->inputBuffer[0])) { + $written = fwrite($stdin, $this->inputBuffer); + $this->inputBuffer = substr($this->inputBuffer, $written); + if (isset($this->inputBuffer[0])) { + return [$this->pipes[0]]; + } + } + + if ($input) { + while (true) { + $data = fread($input, self::CHUNK_SIZE); + if (!isset($data[0])) { + break; + } + $written = fwrite($stdin, $data); + $data = substr($data, $written); + if (isset($data[0])) { + $this->inputBuffer = $data; + + return [$this->pipes[0]]; + } + } + if (feof($input)) { + if ($this->input instanceof \Iterator) { + $this->input->next(); + } else { + $this->input = null; + } + } + } + } + + // no input to read on resource, buffer is empty + if (!isset($this->inputBuffer[0]) && !($this->input instanceof \Iterator ? $this->input->valid() : $this->input)) { + $this->input = null; + fclose($this->pipes[0]); + unset($this->pipes[0]); + } elseif (!$w) { + return [$this->pipes[0]]; + } + + return null; + } + + /** + * @internal + */ + public function handleError(int $type, string $msg): void + { + $this->lastError = $msg; + } +} diff --git a/lib/symfony/process/Pipes/PipesInterface.php b/lib/symfony/process/Pipes/PipesInterface.php new file mode 100644 index 0000000000..967f8de7fa --- /dev/null +++ b/lib/symfony/process/Pipes/PipesInterface.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Pipes; + +/** + * PipesInterface manages descriptors and pipes for the use of proc_open. + * + * @author Romain Neutron + * + * @internal + */ +interface PipesInterface +{ + public const CHUNK_SIZE = 16384; + + /** + * Returns an array of descriptors for the use of proc_open. + */ + public function getDescriptors(): array; + + /** + * Returns an array of filenames indexed by their related stream in case these pipes use temporary files. + * + * @return string[] + */ + public function getFiles(): array; + + /** + * Reads data in file handles and pipes. + * + * @param bool $blocking Whether to use blocking calls or not + * @param bool $close Whether to close pipes if they've reached EOF + * + * @return string[] An array of read data indexed by their fd + */ + public function readAndWrite(bool $blocking, bool $close = false): array; + + /** + * Returns if the current state has open file handles or pipes. + */ + public function areOpen(): bool; + + /** + * Returns if pipes are able to read output. + */ + public function haveReadSupport(): bool; + + /** + * Closes file handles and pipes. + */ + public function close(): void; +} diff --git a/lib/symfony/process/Pipes/UnixPipes.php b/lib/symfony/process/Pipes/UnixPipes.php new file mode 100644 index 0000000000..8838c68af0 --- /dev/null +++ b/lib/symfony/process/Pipes/UnixPipes.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Pipes; + +use Symfony\Component\Process\Process; + +/** + * UnixPipes implementation uses unix pipes as handles. + * + * @author Romain Neutron + * + * @internal + */ +class UnixPipes extends AbstractPipes +{ + private ?bool $ttyMode; + private bool $ptyMode; + private bool $haveReadSupport; + + public function __construct(?bool $ttyMode, bool $ptyMode, mixed $input, bool $haveReadSupport) + { + $this->ttyMode = $ttyMode; + $this->ptyMode = $ptyMode; + $this->haveReadSupport = $haveReadSupport; + + parent::__construct($input); + } + + public function __serialize(): array + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + public function __unserialize(array $data): void + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + public function __destruct() + { + $this->close(); + } + + public function getDescriptors(): array + { + if (!$this->haveReadSupport) { + $nullstream = fopen('/dev/null', 'c'); + + return [ + ['pipe', 'r'], + $nullstream, + $nullstream, + ]; + } + + if ($this->ttyMode) { + return [ + ['file', '/dev/tty', 'r'], + ['file', '/dev/tty', 'w'], + ['file', '/dev/tty', 'w'], + ]; + } + + if ($this->ptyMode && Process::isPtySupported()) { + return [ + ['pty'], + ['pty'], + ['pipe', 'w'], // stderr needs to be in a pipe to correctly split error and output, since PHP will use the same stream for both + ]; + } + + return [ + ['pipe', 'r'], + ['pipe', 'w'], // stdout + ['pipe', 'w'], // stderr + ]; + } + + public function getFiles(): array + { + return []; + } + + public function readAndWrite(bool $blocking, bool $close = false): array + { + $this->unblock(); + $w = $this->write(); + + $read = $e = []; + $r = $this->pipes; + unset($r[0]); + + // let's have a look if something changed in streams + set_error_handler($this->handleError(...)); + if (($r || $w) && false === stream_select($r, $w, $e, 0, $blocking ? Process::TIMEOUT_PRECISION * 1E6 : 0)) { + restore_error_handler(); + // if a system call has been interrupted, forget about it, let's try again + // otherwise, an error occurred, let's reset pipes + if (!$this->hasSystemCallBeenInterrupted()) { + $this->pipes = []; + } + + return $read; + } + restore_error_handler(); + + foreach ($r as $pipe) { + // prior PHP 5.4 the array passed to stream_select is modified and + // lose key association, we have to find back the key + $read[$type = array_search($pipe, $this->pipes, true)] = ''; + + do { + $data = @fread($pipe, self::CHUNK_SIZE); + $read[$type] .= $data; + } while (isset($data[0]) && ($close || isset($data[self::CHUNK_SIZE - 1]))); + + if (!isset($read[$type][0])) { + unset($read[$type]); + } + + if ($close && feof($pipe)) { + fclose($pipe); + unset($this->pipes[$type]); + } + } + + return $read; + } + + public function haveReadSupport(): bool + { + return $this->haveReadSupport; + } + + public function areOpen(): bool + { + return (bool) $this->pipes; + } +} diff --git a/lib/symfony/process/Pipes/WindowsPipes.php b/lib/symfony/process/Pipes/WindowsPipes.php new file mode 100644 index 0000000000..bec37358c7 --- /dev/null +++ b/lib/symfony/process/Pipes/WindowsPipes.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Pipes; + +use Symfony\Component\Process\Exception\RuntimeException; +use Symfony\Component\Process\Process; + +/** + * WindowsPipes implementation uses temporary files as handles. + * + * @see https://bugs.php.net/51800 + * @see https://bugs.php.net/65650 + * + * @author Romain Neutron + * + * @internal + */ +class WindowsPipes extends AbstractPipes +{ + private array $files = []; + private array $fileHandles = []; + private array $lockHandles = []; + private array $readBytes = [ + Process::STDOUT => 0, + Process::STDERR => 0, + ]; + private bool $haveReadSupport; + + public function __construct(mixed $input, bool $haveReadSupport) + { + $this->haveReadSupport = $haveReadSupport; + + if ($this->haveReadSupport) { + // Fix for PHP bug #51800: reading from STDOUT pipe hangs forever on Windows if the output is too big. + // Workaround for this problem is to use temporary files instead of pipes on Windows platform. + // + // @see https://bugs.php.net/51800 + $pipes = [ + Process::STDOUT => Process::OUT, + Process::STDERR => Process::ERR, + ]; + $tmpDir = sys_get_temp_dir(); + $lastError = 'unknown reason'; + set_error_handler(function ($type, $msg) use (&$lastError) { $lastError = $msg; }); + for ($i = 0;; ++$i) { + foreach ($pipes as $pipe => $name) { + $file = \sprintf('%s\\sf_proc_%02X.%s', $tmpDir, $i, $name); + + if (!$h = fopen($file.'.lock', 'w')) { + if (file_exists($file.'.lock')) { + continue 2; + } + restore_error_handler(); + throw new RuntimeException('A temporary file could not be opened to write the process output: '.$lastError); + } + if (!flock($h, \LOCK_EX | \LOCK_NB)) { + continue 2; + } + if (isset($this->lockHandles[$pipe])) { + flock($this->lockHandles[$pipe], \LOCK_UN); + fclose($this->lockHandles[$pipe]); + } + $this->lockHandles[$pipe] = $h; + + if (!($h = fopen($file, 'w')) || !fclose($h) || !$h = fopen($file, 'r')) { + flock($this->lockHandles[$pipe], \LOCK_UN); + fclose($this->lockHandles[$pipe]); + unset($this->lockHandles[$pipe]); + continue 2; + } + $this->fileHandles[$pipe] = $h; + $this->files[$pipe] = $file; + } + break; + } + restore_error_handler(); + } + + parent::__construct($input); + } + + public function __serialize(): array + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + public function __unserialize(array $data): void + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + public function __destruct() + { + $this->close(); + } + + public function getDescriptors(): array + { + if (!$this->haveReadSupport) { + $nullstream = fopen('NUL', 'c'); + + return [ + ['pipe', 'r'], + $nullstream, + $nullstream, + ]; + } + + // We're not using pipe on Windows platform as it hangs (https://bugs.php.net/51800) + // We're not using file handles as it can produce corrupted output https://bugs.php.net/65650 + // So we redirect output within the commandline and pass the nul device to the process + return [ + ['pipe', 'r'], + ['file', 'NUL', 'w'], + ['file', 'NUL', 'w'], + ]; + } + + public function getFiles(): array + { + return $this->files; + } + + public function readAndWrite(bool $blocking, bool $close = false): array + { + $this->unblock(); + $w = $this->write(); + $read = $r = $e = []; + + if ($blocking) { + if ($w) { + @stream_select($r, $w, $e, 0, Process::TIMEOUT_PRECISION * 1E6); + } elseif ($this->fileHandles) { + usleep((int) (Process::TIMEOUT_PRECISION * 1E6)); + } + } + foreach ($this->fileHandles as $type => $fileHandle) { + $data = stream_get_contents($fileHandle, -1, $this->readBytes[$type]); + + if (isset($data[0])) { + $this->readBytes[$type] += \strlen($data); + $read[$type] = $data; + } + if ($close) { + ftruncate($fileHandle, 0); + fclose($fileHandle); + flock($this->lockHandles[$type], \LOCK_UN); + fclose($this->lockHandles[$type]); + unset($this->fileHandles[$type], $this->lockHandles[$type]); + } + } + + return $read; + } + + public function haveReadSupport(): bool + { + return $this->haveReadSupport; + } + + public function areOpen(): bool + { + return $this->pipes && $this->fileHandles; + } + + public function close(): void + { + parent::close(); + foreach ($this->fileHandles as $type => $handle) { + ftruncate($handle, 0); + fclose($handle); + flock($this->lockHandles[$type], \LOCK_UN); + fclose($this->lockHandles[$type]); + } + $this->fileHandles = $this->lockHandles = []; + } +} diff --git a/lib/symfony/process/Process.php b/lib/symfony/process/Process.php new file mode 100644 index 0000000000..ce730f98e8 --- /dev/null +++ b/lib/symfony/process/Process.php @@ -0,0 +1,1603 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +use Symfony\Component\Process\Exception\InvalidArgumentException; +use Symfony\Component\Process\Exception\LogicException; +use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Exception\ProcessSignaledException; +use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\Exception\RuntimeException; +use Symfony\Component\Process\Pipes\UnixPipes; +use Symfony\Component\Process\Pipes\WindowsPipes; + +/** + * Process is a thin wrapper around proc_* functions to easily + * start independent PHP processes. + * + * @author Fabien Potencier + * @author Romain Neutron + * + * @implements \IteratorAggregate + */ +class Process implements \IteratorAggregate +{ + public const ERR = 'err'; + public const OUT = 'out'; + + public const STATUS_READY = 'ready'; + public const STATUS_STARTED = 'started'; + public const STATUS_TERMINATED = 'terminated'; + + public const STDIN = 0; + public const STDOUT = 1; + public const STDERR = 2; + + // Timeout Precision in seconds. + public const TIMEOUT_PRECISION = 0.2; + + public const ITER_NON_BLOCKING = 1; // By default, iterating over outputs is a blocking call, use this flag to make it non-blocking + public const ITER_KEEP_OUTPUT = 2; // By default, outputs are cleared while iterating, use this flag to keep them in memory + public const ITER_SKIP_OUT = 4; // Use this flag to skip STDOUT while iterating + public const ITER_SKIP_ERR = 8; // Use this flag to skip STDERR while iterating + + private ?\Closure $callback = null; + private array|string $commandline; + private ?string $cwd; + private array $env = []; + /** @var resource|string|\Iterator|null */ + private $input; + private ?float $starttime = null; + private ?float $lastOutputTime = null; + private ?float $timeout = null; + private ?float $idleTimeout = null; + private ?int $exitcode = null; + private array $fallbackStatus = []; + private array $processInformation; + private bool $outputDisabled = false; + /** @var resource */ + private $stdout; + /** @var resource */ + private $stderr; + /** @var resource|null */ + private $process; + private string $status = self::STATUS_READY; + private int $incrementalOutputOffset = 0; + private int $incrementalErrorOutputOffset = 0; + private bool $tty = false; + private bool $pty; + private array $options = ['suppress_errors' => true, 'bypass_shell' => true]; + + private WindowsPipes|UnixPipes $processPipes; + + private ?int $latestSignal = null; + + private static ?bool $sigchild = null; + + /** + * Exit codes translation table. + * + * User-defined errors must use exit codes in the 64-113 range. + */ + public static $exitCodes = [ + 0 => 'OK', + 1 => 'General error', + 2 => 'Misuse of shell builtins', + + 126 => 'Invoked command cannot execute', + 127 => 'Command not found', + 128 => 'Invalid exit argument', + + // signals + 129 => 'Hangup', + 130 => 'Interrupt', + 131 => 'Quit and dump core', + 132 => 'Illegal instruction', + 133 => 'Trace/breakpoint trap', + 134 => 'Process aborted', + 135 => 'Bus error: "access to undefined portion of memory object"', + 136 => 'Floating point exception: "erroneous arithmetic operation"', + 137 => 'Kill (terminate immediately)', + 138 => 'User-defined 1', + 139 => 'Segmentation violation', + 140 => 'User-defined 2', + 141 => 'Write to pipe with no one reading', + 142 => 'Signal raised by alarm', + 143 => 'Termination (request to terminate)', + // 144 - not defined + 145 => 'Child process terminated, stopped (or continued*)', + 146 => 'Continue if stopped', + 147 => 'Stop executing temporarily', + 148 => 'Terminal stop signal', + 149 => 'Background process attempting to read from tty ("in")', + 150 => 'Background process attempting to write to tty ("out")', + 151 => 'Urgent data available on socket', + 152 => 'CPU time limit exceeded', + 153 => 'File size limit exceeded', + 154 => 'Signal raised by timer counting virtual time: "virtual timer expired"', + 155 => 'Profiling timer expired', + // 156 - not defined + 157 => 'Pollable event', + // 158 - not defined + 159 => 'Bad syscall', + ]; + + /** + * @param array $command The command to run and its arguments listed as separate entries + * @param string|null $cwd The working directory or null to use the working dir of the current PHP process + * @param array|null $env The environment variables or null to use the same environment as the current PHP process + * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input + * @param int|float|null $timeout The timeout in seconds or null to disable + * + * @throws LogicException When proc_open is not installed + */ + public function __construct(array $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60) + { + if (!\function_exists('proc_open')) { + throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.'); + } + + $this->commandline = $command; + $this->cwd = $cwd; + + // on Windows, if the cwd changed via chdir(), proc_open defaults to the dir where PHP was started + // on Gnu/Linux, PHP builds with --enable-maintainer-zts are also affected + // @see : https://bugs.php.net/51800 + // @see : https://bugs.php.net/50524 + if (null === $this->cwd && (\defined('ZEND_THREAD_SAFE') || '\\' === \DIRECTORY_SEPARATOR)) { + $this->cwd = getcwd(); + } + if (null !== $env) { + $this->setEnv($env); + } + + $this->setInput($input); + $this->setTimeout($timeout); + $this->pty = false; + } + + /** + * Creates a Process instance as a command-line to be run in a shell wrapper. + * + * Command-lines are parsed by the shell of your OS (/bin/sh on Unix-like, cmd.exe on Windows.) + * This allows using e.g. pipes or conditional execution. In this mode, signals are sent to the + * shell wrapper and not to your commands. + * + * In order to inject dynamic values into command-lines, we strongly recommend using placeholders. + * This will save escaping values, which is not portable nor secure anyway: + * + * $process = Process::fromShellCommandline('my_command "${:MY_VAR}"'); + * $process->run(null, ['MY_VAR' => $theValue]); + * + * @param string $command The command line to pass to the shell of the OS + * @param string|null $cwd The working directory or null to use the working dir of the current PHP process + * @param array|null $env The environment variables or null to use the same environment as the current PHP process + * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input + * @param int|float|null $timeout The timeout in seconds or null to disable + * + * @throws LogicException When proc_open is not installed + */ + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static + { + $process = new static([], $cwd, $env, $input, $timeout); + $process->commandline = $command; + + return $process; + } + + public function __serialize(): array + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + public function __unserialize(array $data): void + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + public function __destruct() + { + if ($this->options['create_new_console'] ?? false) { + $this->processPipes->close(); + } else { + $this->stop(0); + } + } + + public function __clone() + { + $this->resetProcessData(); + } + + /** + * Runs the process. + * + * The callback receives the type of output (out or err) and + * some bytes from the output in real-time. It allows to have feedback + * from the independent process during execution. + * + * The STDOUT and STDERR are also available after the process is finished + * via the getOutput() and getErrorOutput() methods. + * + * @param callable|null $callback A PHP callback to run whenever there is some + * output available on STDOUT or STDERR + * + * @return int The exit status code + * + * @throws RuntimeException When process can't be launched + * @throws RuntimeException When process is already running + * @throws ProcessTimedOutException When process timed out + * @throws ProcessSignaledException When process stopped after receiving signal + * @throws LogicException In case a callback is provided and output has been disabled + * + * @final + */ + public function run(?callable $callback = null, array $env = []): int + { + $this->start($callback, $env); + + return $this->wait(); + } + + /** + * Runs the process. + * + * This is identical to run() except that an exception is thrown if the process + * exits with a non-zero exit code. + * + * @return $this + * + * @throws ProcessFailedException if the process didn't terminate successfully + * + * @final + */ + public function mustRun(?callable $callback = null, array $env = []): static + { + if (0 !== $this->run($callback, $env)) { + throw new ProcessFailedException($this); + } + + return $this; + } + + /** + * Starts the process and returns after writing the input to STDIN. + * + * This method blocks until all STDIN data is sent to the process then it + * returns while the process runs in the background. + * + * The termination of the process can be awaited with wait(). + * + * The callback receives the type of output (out or err) and some bytes from + * the output in real-time while writing the standard input to the process. + * It allows to have feedback from the independent process during execution. + * + * @param callable|null $callback A PHP callback to run whenever there is some + * output available on STDOUT or STDERR + * + * @return void + * + * @throws RuntimeException When process can't be launched + * @throws RuntimeException When process is already running + * @throws LogicException In case a callback is provided and output has been disabled + */ + public function start(?callable $callback = null, array $env = []) + { + if ($this->isRunning()) { + throw new RuntimeException('Process is already running.'); + } + + $this->resetProcessData(); + $this->starttime = $this->lastOutputTime = microtime(true); + $this->callback = $this->buildCallback($callback); + $descriptors = $this->getDescriptors(null !== $callback); + + if ($this->env) { + $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->env, $env, 'strcasecmp') : $this->env; + } + + $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv(); + + if (\is_array($commandline = $this->commandline)) { + $commandline = implode(' ', array_map($this->escapeArgument(...), $commandline)); + + if ('\\' !== \DIRECTORY_SEPARATOR) { + // exec is mandatory to deal with sending a signal to the process + $commandline = 'exec '.$commandline; + } + } else { + $commandline = $this->replacePlaceholders($commandline, $env); + } + + if ('\\' === \DIRECTORY_SEPARATOR) { + $commandline = $this->prepareWindowsCommandLine($commandline, $env); + } elseif ($this->isSigchildEnabled()) { + // last exit code is output on the fourth pipe and caught to work around --enable-sigchild + $descriptors[3] = ['pipe', 'w']; + + // See https://unix.stackexchange.com/questions/71205/background-process-pipe-input + $commandline = '{ ('.$commandline.') <&3 3<&- 3>/dev/null & } 3<&0;'; + $commandline .= 'pid=$!; echo $pid >&3; wait $pid 2>/dev/null; code=$?; echo $code >&3; exit $code'; + } + + $envPairs = []; + foreach ($env as $k => $v) { + if (false !== $v && false === \in_array($k, ['argc', 'argv', 'ARGC', 'ARGV'], true)) { + $envPairs[] = $k.'='.$v; + } + } + + if (!is_dir($this->cwd)) { + throw new RuntimeException(\sprintf('The provided cwd "%s" does not exist.', $this->cwd)); + } + + $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + + if (!$process) { + throw new RuntimeException('Unable to launch a new process.'); + } + $this->process = $process; + $this->status = self::STATUS_STARTED; + + if (isset($descriptors[3])) { + $this->fallbackStatus['pid'] = (int) fgets($this->processPipes->pipes[3]); + } + + if ($this->tty) { + return; + } + + $this->updateStatus(false); + $this->checkTimeout(); + } + + /** + * Restarts the process. + * + * Be warned that the process is cloned before being started. + * + * @param callable|null $callback A PHP callback to run whenever there is some + * output available on STDOUT or STDERR + * + * @throws RuntimeException When process can't be launched + * @throws RuntimeException When process is already running + * + * @see start() + * + * @final + */ + public function restart(?callable $callback = null, array $env = []): static + { + if ($this->isRunning()) { + throw new RuntimeException('Process is already running.'); + } + + $process = clone $this; + $process->start($callback, $env); + + return $process; + } + + /** + * Waits for the process to terminate. + * + * The callback receives the type of output (out or err) and some bytes + * from the output in real-time while writing the standard input to the process. + * It allows to have feedback from the independent process during execution. + * + * @param callable|null $callback A valid PHP callback + * + * @return int The exitcode of the process + * + * @throws ProcessTimedOutException When process timed out + * @throws ProcessSignaledException When process stopped after receiving signal + * @throws LogicException When process is not yet started + */ + public function wait(?callable $callback = null): int + { + $this->requireProcessIsStarted(__FUNCTION__); + + $this->updateStatus(false); + + if (null !== $callback) { + if (!$this->processPipes->haveReadSupport()) { + $this->stop(0); + throw new LogicException('Pass the callback to the "Process::start" method or call enableOutput to use a callback with "Process::wait".'); + } + $this->callback = $this->buildCallback($callback); + } + + do { + $this->checkTimeout(); + $running = $this->isRunning() && ('\\' === \DIRECTORY_SEPARATOR || $this->processPipes->areOpen()); + $this->readPipes($running, '\\' !== \DIRECTORY_SEPARATOR || !$running); + } while ($running); + + while ($this->isRunning()) { + $this->checkTimeout(); + usleep(1000); + } + + if ($this->processInformation['signaled'] && $this->processInformation['termsig'] !== $this->latestSignal) { + throw new ProcessSignaledException($this); + } + + return $this->exitcode; + } + + /** + * Waits until the callback returns true. + * + * The callback receives the type of output (out or err) and some bytes + * from the output in real-time while writing the standard input to the process. + * It allows to have feedback from the independent process during execution. + * + * @throws RuntimeException When process timed out + * @throws LogicException When process is not yet started + * @throws ProcessTimedOutException In case the timeout was reached + */ + public function waitUntil(callable $callback): bool + { + $this->requireProcessIsStarted(__FUNCTION__); + $this->updateStatus(false); + + if (!$this->processPipes->haveReadSupport()) { + $this->stop(0); + throw new LogicException('Pass the callback to the "Process::start" method or call enableOutput to use a callback with "Process::waitUntil".'); + } + $callback = $this->buildCallback($callback); + + $ready = false; + while (true) { + $this->checkTimeout(); + $running = '\\' === \DIRECTORY_SEPARATOR ? $this->isRunning() : $this->processPipes->areOpen(); + $output = $this->processPipes->readAndWrite($running, '\\' !== \DIRECTORY_SEPARATOR || !$running); + + foreach ($output as $type => $data) { + if (3 !== $type) { + $ready = $callback(self::STDOUT === $type ? self::OUT : self::ERR, $data) || $ready; + } elseif (!isset($this->fallbackStatus['signaled'])) { + $this->fallbackStatus['exitcode'] = (int) $data; + } + } + if ($ready) { + return true; + } + if (!$running) { + return false; + } + + usleep(1000); + } + } + + /** + * Returns the Pid (process identifier), if applicable. + * + * @return int|null The process id if running, null otherwise + */ + public function getPid(): ?int + { + return $this->isRunning() ? $this->processInformation['pid'] : null; + } + + /** + * Sends a POSIX signal to the process. + * + * @param int $signal A valid POSIX signal (see https://php.net/pcntl.constants) + * + * @return $this + * + * @throws LogicException In case the process is not running + * @throws RuntimeException In case --enable-sigchild is activated and the process can't be killed + * @throws RuntimeException In case of failure + */ + public function signal(int $signal): static + { + $this->doSignal($signal, true); + + return $this; + } + + /** + * Disables fetching output and error output from the underlying process. + * + * @return $this + * + * @throws RuntimeException In case the process is already running + * @throws LogicException if an idle timeout is set + */ + public function disableOutput(): static + { + if ($this->isRunning()) { + throw new RuntimeException('Disabling output while the process is running is not possible.'); + } + if (null !== $this->idleTimeout) { + throw new LogicException('Output cannot be disabled while an idle timeout is set.'); + } + + $this->outputDisabled = true; + + return $this; + } + + /** + * Enables fetching output and error output from the underlying process. + * + * @return $this + * + * @throws RuntimeException In case the process is already running + */ + public function enableOutput(): static + { + if ($this->isRunning()) { + throw new RuntimeException('Enabling output while the process is running is not possible.'); + } + + $this->outputDisabled = false; + + return $this; + } + + /** + * Returns true in case the output is disabled, false otherwise. + */ + public function isOutputDisabled(): bool + { + return $this->outputDisabled; + } + + /** + * Returns the current output of the process (STDOUT). + * + * @throws LogicException in case the output has been disabled + * @throws LogicException In case the process is not started + */ + public function getOutput(): string + { + $this->readPipesForOutput(__FUNCTION__); + + if (false === $ret = stream_get_contents($this->stdout, -1, 0)) { + return ''; + } + + return $ret; + } + + /** + * Returns the output incrementally. + * + * In comparison with the getOutput method which always return the whole + * output, this one returns the new output since the last call. + * + * @throws LogicException in case the output has been disabled + * @throws LogicException In case the process is not started + */ + public function getIncrementalOutput(): string + { + $this->readPipesForOutput(__FUNCTION__); + + $latest = stream_get_contents($this->stdout, -1, $this->incrementalOutputOffset); + $this->incrementalOutputOffset = ftell($this->stdout); + + if (false === $latest) { + return ''; + } + + return $latest; + } + + /** + * Returns an iterator to the output of the process, with the output type as keys (Process::OUT/ERR). + * + * @param int $flags A bit field of Process::ITER_* flags + * + * @return \Generator + * + * @throws LogicException in case the output has been disabled + * @throws LogicException In case the process is not started + */ + public function getIterator(int $flags = 0): \Generator + { + $this->readPipesForOutput(__FUNCTION__, false); + + $clearOutput = !(self::ITER_KEEP_OUTPUT & $flags); + $blocking = !(self::ITER_NON_BLOCKING & $flags); + $yieldOut = !(self::ITER_SKIP_OUT & $flags); + $yieldErr = !(self::ITER_SKIP_ERR & $flags); + + while (null !== $this->callback || ($yieldOut && !feof($this->stdout)) || ($yieldErr && !feof($this->stderr))) { + if ($yieldOut) { + $out = stream_get_contents($this->stdout, -1, $this->incrementalOutputOffset); + + if (isset($out[0])) { + if ($clearOutput) { + $this->clearOutput(); + } else { + $this->incrementalOutputOffset = ftell($this->stdout); + } + + yield self::OUT => $out; + } + } + + if ($yieldErr) { + $err = stream_get_contents($this->stderr, -1, $this->incrementalErrorOutputOffset); + + if (isset($err[0])) { + if ($clearOutput) { + $this->clearErrorOutput(); + } else { + $this->incrementalErrorOutputOffset = ftell($this->stderr); + } + + yield self::ERR => $err; + } + } + + if (!$blocking && !isset($out[0]) && !isset($err[0])) { + yield self::OUT => ''; + } + + $this->checkTimeout(); + $this->readPipesForOutput(__FUNCTION__, $blocking); + } + } + + /** + * Clears the process output. + * + * @return $this + */ + public function clearOutput(): static + { + ftruncate($this->stdout, 0); + fseek($this->stdout, 0); + $this->incrementalOutputOffset = 0; + + return $this; + } + + /** + * Returns the current error output of the process (STDERR). + * + * @throws LogicException in case the output has been disabled + * @throws LogicException In case the process is not started + */ + public function getErrorOutput(): string + { + $this->readPipesForOutput(__FUNCTION__); + + if (false === $ret = stream_get_contents($this->stderr, -1, 0)) { + return ''; + } + + return $ret; + } + + /** + * Returns the errorOutput incrementally. + * + * In comparison with the getErrorOutput method which always return the + * whole error output, this one returns the new error output since the last + * call. + * + * @throws LogicException in case the output has been disabled + * @throws LogicException In case the process is not started + */ + public function getIncrementalErrorOutput(): string + { + $this->readPipesForOutput(__FUNCTION__); + + $latest = stream_get_contents($this->stderr, -1, $this->incrementalErrorOutputOffset); + $this->incrementalErrorOutputOffset = ftell($this->stderr); + + if (false === $latest) { + return ''; + } + + return $latest; + } + + /** + * Clears the process output. + * + * @return $this + */ + public function clearErrorOutput(): static + { + ftruncate($this->stderr, 0); + fseek($this->stderr, 0); + $this->incrementalErrorOutputOffset = 0; + + return $this; + } + + /** + * Returns the exit code returned by the process. + * + * @return int|null The exit status code, null if the Process is not terminated + */ + public function getExitCode(): ?int + { + $this->updateStatus(false); + + return $this->exitcode; + } + + /** + * Returns a string representation for the exit code returned by the process. + * + * This method relies on the Unix exit code status standardization + * and might not be relevant for other operating systems. + * + * @return string|null A string representation for the exit status code, null if the Process is not terminated + * + * @see http://tldp.org/LDP/abs/html/exitcodes.html + * @see http://en.wikipedia.org/wiki/Unix_signal + */ + public function getExitCodeText(): ?string + { + if (null === $exitcode = $this->getExitCode()) { + return null; + } + + return self::$exitCodes[$exitcode] ?? 'Unknown error'; + } + + /** + * Checks if the process ended successfully. + */ + public function isSuccessful(): bool + { + return 0 === $this->getExitCode(); + } + + /** + * Returns true if the child process has been terminated by an uncaught signal. + * + * It always returns false on Windows. + * + * @throws LogicException In case the process is not terminated + */ + public function hasBeenSignaled(): bool + { + $this->requireProcessIsTerminated(__FUNCTION__); + + return $this->processInformation['signaled']; + } + + /** + * Returns the number of the signal that caused the child process to terminate its execution. + * + * It is only meaningful if hasBeenSignaled() returns true. + * + * @throws RuntimeException In case --enable-sigchild is activated + * @throws LogicException In case the process is not terminated + */ + public function getTermSignal(): int + { + $this->requireProcessIsTerminated(__FUNCTION__); + + if ($this->isSigchildEnabled() && -1 === $this->processInformation['termsig']) { + throw new RuntimeException('This PHP has been compiled with --enable-sigchild. Term signal cannot be retrieved.'); + } + + return $this->processInformation['termsig']; + } + + /** + * Returns true if the child process has been stopped by a signal. + * + * It always returns false on Windows. + * + * @throws LogicException In case the process is not terminated + */ + public function hasBeenStopped(): bool + { + $this->requireProcessIsTerminated(__FUNCTION__); + + return $this->processInformation['stopped']; + } + + /** + * Returns the number of the signal that caused the child process to stop its execution. + * + * It is only meaningful if hasBeenStopped() returns true. + * + * @throws LogicException In case the process is not terminated + */ + public function getStopSignal(): int + { + $this->requireProcessIsTerminated(__FUNCTION__); + + return $this->processInformation['stopsig']; + } + + /** + * Checks if the process is currently running. + */ + public function isRunning(): bool + { + if (self::STATUS_STARTED !== $this->status) { + return false; + } + + $this->updateStatus(false); + + return $this->processInformation['running']; + } + + /** + * Checks if the process has been started with no regard to the current state. + */ + public function isStarted(): bool + { + return self::STATUS_READY != $this->status; + } + + /** + * Checks if the process is terminated. + */ + public function isTerminated(): bool + { + $this->updateStatus(false); + + return self::STATUS_TERMINATED == $this->status; + } + + /** + * Gets the process status. + * + * The status is one of: ready, started, terminated. + */ + public function getStatus(): string + { + $this->updateStatus(false); + + return $this->status; + } + + /** + * Stops the process. + * + * @param int|float $timeout The timeout in seconds + * @param int|null $signal A POSIX signal to send in case the process has not stop at timeout, default is SIGKILL (9) + * + * @return int|null The exit-code of the process or null if it's not running + */ + public function stop(float $timeout = 10, ?int $signal = null): ?int + { + $timeoutMicro = microtime(true) + $timeout; + if ($this->isRunning()) { + // given SIGTERM may not be defined and that "proc_terminate" uses the constant value and not the constant itself, we use the same here + $this->doSignal(15, false); + do { + usleep(1000); + } while ($this->isRunning() && microtime(true) < $timeoutMicro); + + if ($this->isRunning()) { + // Avoid exception here: process is supposed to be running, but it might have stopped just + // after this line. In any case, let's silently discard the error, we cannot do anything. + $this->doSignal($signal ?: 9, false); + } + } + + if ($this->isRunning()) { + if (isset($this->fallbackStatus['pid'])) { + unset($this->fallbackStatus['pid']); + + return $this->stop(0, $signal); + } + $this->close(); + } + + return $this->exitcode; + } + + /** + * Adds a line to the STDOUT stream. + * + * @internal + */ + public function addOutput(string $line): void + { + $this->lastOutputTime = microtime(true); + + fseek($this->stdout, 0, \SEEK_END); + fwrite($this->stdout, $line); + fseek($this->stdout, $this->incrementalOutputOffset); + } + + /** + * Adds a line to the STDERR stream. + * + * @internal + */ + public function addErrorOutput(string $line): void + { + $this->lastOutputTime = microtime(true); + + fseek($this->stderr, 0, \SEEK_END); + fwrite($this->stderr, $line); + fseek($this->stderr, $this->incrementalErrorOutputOffset); + } + + /** + * Gets the last output time in seconds. + */ + public function getLastOutputTime(): ?float + { + return $this->lastOutputTime; + } + + /** + * Gets the command line to be executed. + */ + public function getCommandLine(): string + { + return \is_array($this->commandline) ? implode(' ', array_map($this->escapeArgument(...), $this->commandline)) : $this->commandline; + } + + /** + * Gets the process timeout in seconds (max. runtime). + */ + public function getTimeout(): ?float + { + return $this->timeout; + } + + /** + * Gets the process idle timeout in seconds (max. time since last output). + */ + public function getIdleTimeout(): ?float + { + return $this->idleTimeout; + } + + /** + * Sets the process timeout (max. runtime) in seconds. + * + * To disable the timeout, set this value to null. + * + * @return $this + * + * @throws InvalidArgumentException if the timeout is negative + */ + public function setTimeout(?float $timeout): static + { + $this->timeout = $this->validateTimeout($timeout); + + return $this; + } + + /** + * Sets the process idle timeout (max. time since last output) in seconds. + * + * To disable the timeout, set this value to null. + * + * @return $this + * + * @throws LogicException if the output is disabled + * @throws InvalidArgumentException if the timeout is negative + */ + public function setIdleTimeout(?float $timeout): static + { + if (null !== $timeout && $this->outputDisabled) { + throw new LogicException('Idle timeout cannot be set while the output is disabled.'); + } + + $this->idleTimeout = $this->validateTimeout($timeout); + + return $this; + } + + /** + * Enables or disables the TTY mode. + * + * @return $this + * + * @throws RuntimeException In case the TTY mode is not supported + */ + public function setTty(bool $tty): static + { + if ('\\' === \DIRECTORY_SEPARATOR && $tty) { + throw new RuntimeException('TTY mode is not supported on Windows platform.'); + } + + if ($tty && !self::isTtySupported()) { + throw new RuntimeException('TTY mode requires /dev/tty to be read/writable.'); + } + + $this->tty = $tty; + + return $this; + } + + /** + * Checks if the TTY mode is enabled. + */ + public function isTty(): bool + { + return $this->tty; + } + + /** + * Sets PTY mode. + * + * @return $this + */ + public function setPty(bool $bool): static + { + $this->pty = $bool; + + return $this; + } + + /** + * Returns PTY state. + */ + public function isPty(): bool + { + return $this->pty; + } + + /** + * Gets the working directory. + */ + public function getWorkingDirectory(): ?string + { + if (null === $this->cwd) { + // getcwd() will return false if any one of the parent directories does not have + // the readable or search mode set, even if the current directory does + return getcwd() ?: null; + } + + return $this->cwd; + } + + /** + * Sets the current working directory. + * + * @return $this + */ + public function setWorkingDirectory(string $cwd): static + { + $this->cwd = $cwd; + + return $this; + } + + /** + * Gets the environment variables. + */ + public function getEnv(): array + { + return $this->env; + } + + /** + * Sets the environment variables. + * + * @param array $env The new environment variables + * + * @return $this + */ + public function setEnv(array $env): static + { + $this->env = $env; + + return $this; + } + + /** + * Gets the Process input. + * + * @return resource|string|\Iterator|null + */ + public function getInput() + { + return $this->input; + } + + /** + * Sets the input. + * + * This content will be passed to the underlying process standard input. + * + * @param string|resource|\Traversable|self|null $input The content + * + * @return $this + * + * @throws LogicException In case the process is running + */ + public function setInput(mixed $input): static + { + if ($this->isRunning()) { + throw new LogicException('Input cannot be set while the process is running.'); + } + + $this->input = ProcessUtils::validateInput(__METHOD__, $input); + + return $this; + } + + /** + * Performs a check between the timeout definition and the time the process started. + * + * In case you run a background process (with the start method), you should + * trigger this method regularly to ensure the process timeout + * + * @return void + * + * @throws ProcessTimedOutException In case the timeout was reached + */ + public function checkTimeout() + { + if (self::STATUS_STARTED !== $this->status) { + return; + } + + if (null !== $this->timeout && $this->timeout < microtime(true) - $this->starttime) { + $this->stop(0); + + throw new ProcessTimedOutException($this, ProcessTimedOutException::TYPE_GENERAL); + } + + if (null !== $this->idleTimeout && $this->idleTimeout < microtime(true) - $this->lastOutputTime) { + $this->stop(0); + + throw new ProcessTimedOutException($this, ProcessTimedOutException::TYPE_IDLE); + } + } + + /** + * @throws LogicException in case process is not started + */ + public function getStartTime(): float + { + if (!$this->isStarted()) { + throw new LogicException('Start time is only available after process start.'); + } + + return $this->starttime; + } + + /** + * Defines options to pass to the underlying proc_open(). + * + * @see https://php.net/proc_open for the options supported by PHP. + * + * Enabling the "create_new_console" option allows a subprocess to continue + * to run after the main process exited, on both Windows and *nix + * + * @return void + */ + public function setOptions(array $options) + { + if ($this->isRunning()) { + throw new RuntimeException('Setting options while the process is running is not possible.'); + } + + $defaultOptions = $this->options; + $existingOptions = ['blocking_pipes', 'create_process_group', 'create_new_console']; + + foreach ($options as $key => $value) { + if (!\in_array($key, $existingOptions)) { + $this->options = $defaultOptions; + throw new LogicException(\sprintf('Invalid option "%s" passed to "%s()". Supported options are "%s".', $key, __METHOD__, implode('", "', $existingOptions))); + } + $this->options[$key] = $value; + } + } + + /** + * Returns whether TTY is supported on the current operating system. + */ + public static function isTtySupported(): bool + { + static $isTtySupported; + + return $isTtySupported ??= ('/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT) && @is_writable('/dev/tty')); + } + + /** + * Returns whether PTY is supported on the current operating system. + */ + public static function isPtySupported(): bool + { + static $result; + + if (null !== $result) { + return $result; + } + + if ('\\' === \DIRECTORY_SEPARATOR) { + return $result = false; + } + + return $result = (bool) @proc_open('echo 1 >/dev/null', [['pty'], ['pty'], ['pty']], $pipes); + } + + /** + * Creates the descriptors needed by the proc_open. + */ + private function getDescriptors(bool $hasCallback): array + { + if ($this->input instanceof \Iterator) { + $this->input->rewind(); + } + if ('\\' === \DIRECTORY_SEPARATOR) { + $this->processPipes = new WindowsPipes($this->input, !$this->outputDisabled || $hasCallback); + } else { + $this->processPipes = new UnixPipes($this->isTty(), $this->isPty(), $this->input, !$this->outputDisabled || $hasCallback); + } + + return $this->processPipes->getDescriptors(); + } + + /** + * Builds up the callback used by wait(). + * + * The callbacks adds all occurred output to the specific buffer and calls + * the user callback (if present) with the received output. + * + * @param callable|null $callback The user defined PHP callback + */ + protected function buildCallback(?callable $callback = null): \Closure + { + if ($this->outputDisabled) { + return fn ($type, $data): bool => null !== $callback && $callback($type, $data); + } + + $out = self::OUT; + + return function ($type, $data) use ($callback, $out): bool { + if ($out == $type) { + $this->addOutput($data); + } else { + $this->addErrorOutput($data); + } + + return null !== $callback && $callback($type, $data); + }; + } + + /** + * Updates the status of the process, reads pipes. + * + * @param bool $blocking Whether to use a blocking read call + * + * @return void + */ + protected function updateStatus(bool $blocking) + { + if (self::STATUS_STARTED !== $this->status) { + return; + } + + if ($this->processInformation['running'] ?? true) { + $this->processInformation = proc_get_status($this->process); + } + $running = $this->processInformation['running']; + + $this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running); + + if ($this->fallbackStatus && $this->isSigchildEnabled()) { + $this->processInformation = $this->fallbackStatus + $this->processInformation; + } + + if (!$running) { + $this->close(); + } + } + + /** + * Returns whether PHP has been compiled with the '--enable-sigchild' option or not. + */ + protected function isSigchildEnabled(): bool + { + if (null !== self::$sigchild) { + return self::$sigchild; + } + + if (!\function_exists('phpinfo')) { + return self::$sigchild = false; + } + + ob_start(); + phpinfo(\INFO_GENERAL); + + return self::$sigchild = str_contains(ob_get_clean(), '--enable-sigchild'); + } + + /** + * Reads pipes for the freshest output. + * + * @param string $caller The name of the method that needs fresh outputs + * @param bool $blocking Whether to use blocking calls or not + * + * @throws LogicException in case output has been disabled or process is not started + */ + private function readPipesForOutput(string $caller, bool $blocking = false): void + { + if ($this->outputDisabled) { + throw new LogicException('Output has been disabled.'); + } + + $this->requireProcessIsStarted($caller); + + $this->updateStatus($blocking); + } + + /** + * Validates and returns the filtered timeout. + * + * @throws InvalidArgumentException if the given timeout is a negative number + */ + private function validateTimeout(?float $timeout): ?float + { + $timeout = (float) $timeout; + + if (0.0 === $timeout) { + $timeout = null; + } elseif ($timeout < 0) { + throw new InvalidArgumentException('The timeout value must be a valid positive integer or float number.'); + } + + return $timeout; + } + + /** + * Reads pipes, executes callback. + * + * @param bool $blocking Whether to use blocking calls or not + * @param bool $close Whether to close file handles or not + */ + private function readPipes(bool $blocking, bool $close): void + { + $result = $this->processPipes->readAndWrite($blocking, $close); + + $callback = $this->callback; + foreach ($result as $type => $data) { + if (3 !== $type) { + $callback(self::STDOUT === $type ? self::OUT : self::ERR, $data); + } elseif (!isset($this->fallbackStatus['signaled'])) { + $this->fallbackStatus['exitcode'] = (int) $data; + } + } + } + + /** + * Closes process resource, closes file handles, sets the exitcode. + * + * @return int The exitcode + */ + private function close(): int + { + $this->processPipes->close(); + if ($this->process) { + proc_close($this->process); + $this->process = null; + } + $this->exitcode = $this->processInformation['exitcode']; + $this->status = self::STATUS_TERMINATED; + + if (-1 === $this->exitcode) { + if ($this->processInformation['signaled'] && 0 < $this->processInformation['termsig']) { + // if process has been signaled, no exitcode but a valid termsig, apply Unix convention + $this->exitcode = 128 + $this->processInformation['termsig']; + } elseif ($this->isSigchildEnabled()) { + $this->processInformation['signaled'] = true; + $this->processInformation['termsig'] = -1; + } + } + + // Free memory from self-reference callback created by buildCallback + // Doing so in other contexts like __destruct or by garbage collector is ineffective + // Now pipes are closed, so the callback is no longer necessary + $this->callback = null; + + return $this->exitcode; + } + + /** + * Resets data related to the latest run of the process. + */ + private function resetProcessData(): void + { + $this->starttime = null; + $this->callback = null; + $this->exitcode = null; + $this->fallbackStatus = []; + $this->processInformation = []; + $this->stdout = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); + $this->stderr = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); + $this->process = null; + $this->latestSignal = null; + $this->status = self::STATUS_READY; + $this->incrementalOutputOffset = 0; + $this->incrementalErrorOutputOffset = 0; + } + + /** + * Sends a POSIX signal to the process. + * + * @param int $signal A valid POSIX signal (see https://php.net/pcntl.constants) + * @param bool $throwException Whether to throw exception in case signal failed + * + * @throws LogicException In case the process is not running + * @throws RuntimeException In case --enable-sigchild is activated and the process can't be killed + * @throws RuntimeException In case of failure + */ + private function doSignal(int $signal, bool $throwException): bool + { + if (null === $pid = $this->getPid()) { + if ($throwException) { + throw new LogicException('Cannot send signal on a non running process.'); + } + + return false; + } + + if ('\\' === \DIRECTORY_SEPARATOR) { + exec(\sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode); + if ($exitCode && $this->isRunning()) { + if ($throwException) { + throw new RuntimeException(\sprintf('Unable to kill the process (%s).', implode(' ', $output))); + } + + return false; + } + } else { + if (!$this->isSigchildEnabled()) { + $ok = @proc_terminate($this->process, $signal); + } elseif (\function_exists('posix_kill')) { + $ok = @posix_kill($pid, $signal); + } elseif ($ok = proc_open(\sprintf('kill -%d %d', $signal, $pid), [2 => ['pipe', 'w']], $pipes)) { + $ok = false === fgets($pipes[2]); + } + if (!$ok) { + if ($throwException) { + throw new RuntimeException(\sprintf('Error while sending signal "%s".', $signal)); + } + + return false; + } + } + + $this->latestSignal = $signal; + $this->fallbackStatus['signaled'] = true; + $this->fallbackStatus['exitcode'] = -1; + $this->fallbackStatus['termsig'] = $this->latestSignal; + + return true; + } + + private function prepareWindowsCommandLine(string $cmd, array &$env): string + { + $uid = uniqid('', true); + $cmd = preg_replace_callback( + '/"(?:( + [^"%!^]*+ + (?: + (?: !LF! | "(?:\^[%!^])?+" ) + [^"%!^]*+ + )++ + ) | [^"]*+ )"/x', + function ($m) use (&$env, $uid) { + static $varCount = 0; + static $varCache = []; + if (!isset($m[1])) { + return $m[0]; + } + if (isset($varCache[$m[0]])) { + return $varCache[$m[0]]; + } + if (str_contains($value = $m[1], "\0")) { + $value = str_replace("\0", '?', $value); + } + if (false === strpbrk($value, "\"%!\n")) { + return '"'.$value.'"'; + } + + $value = str_replace(['!LF!', '"^!"', '"^%"', '"^^"', '""'], ["\n", '!', '%', '^', '"'], $value); + $value = '"'.preg_replace('/(\\\\*)"/', '$1$1\\"', $value).'"'; + $var = $uid.++$varCount; + + $env[$var] = $value; + + return $varCache[$m[0]] = '!'.$var.'!'; + }, + $cmd + ); + + static $comSpec; + + if (!$comSpec && $comSpec = (new ExecutableFinder())->find('cmd.exe')) { + // Escape according to CommandLineToArgvW rules + $comSpec = '"'.preg_replace('{(\\\\*+)"}', '$1$1\"', $comSpec).'"'; + } + + $cmd = ($comSpec ?? 'cmd').' /V:ON /E:ON /D /C ('.str_replace("\n", ' ', $cmd).')'; + foreach ($this->processPipes->getFiles() as $offset => $filename) { + $cmd .= ' '.$offset.'>"'.$filename.'"'; + } + + return $cmd; + } + + /** + * Ensures the process is running or terminated, throws a LogicException if the process has a not started. + * + * @throws LogicException if the process has not run + */ + private function requireProcessIsStarted(string $functionName): void + { + if (!$this->isStarted()) { + throw new LogicException(\sprintf('Process must be started before calling "%s()".', $functionName)); + } + } + + /** + * Ensures the process is terminated, throws a LogicException if the process has a status different than "terminated". + * + * @throws LogicException if the process is not yet terminated + */ + private function requireProcessIsTerminated(string $functionName): void + { + if (!$this->isTerminated()) { + throw new LogicException(\sprintf('Process must be terminated before calling "%s()".', $functionName)); + } + } + + /** + * Escapes a string to be used as a shell argument. + */ + private function escapeArgument(?string $argument): string + { + if ('' === $argument || null === $argument) { + return '""'; + } + if ('\\' !== \DIRECTORY_SEPARATOR) { + return "'".str_replace("'", "'\\''", $argument)."'"; + } + if (str_contains($argument, "\0")) { + $argument = str_replace("\0", '?', $argument); + } + if (!preg_match('/[()%!^"<>&|\s]/', $argument)) { + return $argument; + } + $argument = preg_replace('/(\\\\+)$/', '$1$1', $argument); + + return '"'.str_replace(['"', '^', '%', '!', "\n"], ['""', '"^^"', '"^%"', '"^!"', '!LF!'], $argument).'"'; + } + + private function replacePlaceholders(string $commandline, array $env): string + { + return preg_replace_callback('/"\$\{:([_a-zA-Z]++[_a-zA-Z0-9]*+)\}"/', function ($matches) use ($commandline, $env) { + if (!isset($env[$matches[1]]) || false === $env[$matches[1]]) { + throw new InvalidArgumentException(\sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline); + } + + return $this->escapeArgument($env[$matches[1]]); + }, $commandline); + } + + private function getDefaultEnv(): array + { + $env = getenv(); + $env = ('\\' === \DIRECTORY_SEPARATOR ? array_intersect_ukey($env, $_SERVER, 'strcasecmp') : array_intersect_key($env, $_SERVER)) ?: $env; + + return $_ENV + ('\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($env, $_ENV, 'strcasecmp') : $env); + } +} diff --git a/lib/symfony/process/ProcessUtils.php b/lib/symfony/process/ProcessUtils.php new file mode 100644 index 0000000000..a2dbde9f7a --- /dev/null +++ b/lib/symfony/process/ProcessUtils.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +use Symfony\Component\Process\Exception\InvalidArgumentException; + +/** + * ProcessUtils is a bunch of utility methods. + * + * This class contains static methods only and is not meant to be instantiated. + * + * @author Martin Hasoň + */ +class ProcessUtils +{ + /** + * This class should not be instantiated. + */ + private function __construct() + { + } + + /** + * Validates and normalizes a Process input. + * + * @param string $caller The name of method call that validates the input + * @param mixed $input The input to validate + * + * @throws InvalidArgumentException In case the input is not valid + */ + public static function validateInput(string $caller, mixed $input): mixed + { + if (null !== $input) { + if (\is_resource($input)) { + return $input; + } + if (\is_scalar($input)) { + return (string) $input; + } + if ($input instanceof Process) { + return $input->getIterator($input::ITER_SKIP_ERR); + } + if ($input instanceof \Iterator) { + return $input; + } + if ($input instanceof \Traversable) { + return new \IteratorIterator($input); + } + + throw new InvalidArgumentException(\sprintf('"%s" only accepts strings, Traversable objects or stream resources.', $caller)); + } + + return $input; + } +} diff --git a/lib/symfony/process/README.md b/lib/symfony/process/README.md new file mode 100644 index 0000000000..afce5e45ee --- /dev/null +++ b/lib/symfony/process/README.md @@ -0,0 +1,13 @@ +Process Component +================= + +The Process component executes commands in sub-processes. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/process.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/lib/symfony/process/composer.json b/lib/symfony/process/composer.json new file mode 100644 index 0000000000..317c07e715 --- /dev/null +++ b/lib/symfony/process/composer.json @@ -0,0 +1,28 @@ +{ + "name": "symfony/process", + "type": "library", + "description": "Executes commands in sub-processes", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Process\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/tests/php-code-style/.php-cs-fixer.dist.php b/tests/php-code-style/.php-cs-fixer.dist.php index 6456c3dd9b..373d97887c 100644 --- a/tests/php-code-style/.php-cs-fixer.dist.php +++ b/tests/php-code-style/.php-cs-fixer.dist.php @@ -5,14 +5,28 @@ echo $APPROOT; $finder = PhpCsFixer\Finder::create() ->in($APPROOT) - ->exclude(['oql', 'data', 'extensions']) - ->notPath(['/env-*/', '/cache-*/', 'lib', 'vendor', 'node_modules', 'config-itop', 'php-static-analysis']) + ->exclude([ + 'core/oql', + 'data', + 'extensions', + 'lib', + 'node_modules', + ]) + ->notPath([ + // Exclude environment folders based on a regex as we can't use a regex in ->exclude() + '|^env-(.*)|', + // Exclude third-party sub-folders + 'vendor', + 'node_modules' + ]) ; $config = new PhpCsFixer\Config(); -return $config->setRiskyAllowed(true) +return $config + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) ->setRules([ - '@PSR12' => true, + '@PSR12' => true, 'indentation_type' => true, 'no_extra_blank_lines' => true, 'array_syntax' => ['syntax' => 'short'],