Compare commits

...

3 Commits

Author SHA1 Message Date
Molkobain
da2ce04fce N°8796 - Parallelize processing to reduce local env. delay 2025-11-14 14:20:09 +01:00
Molkobain
f86fc16d3f N°8796 - Optimize PHP CS Fixer files finder 2025-11-14 14:20:08 +01:00
Molkobain
f83015d208 N°8796 - Add pre-commit hook for automatic cross-platform code style fixing 2025-11-14 14:20:07 +01:00
373 changed files with 35172 additions and 1068 deletions

4
.gitignore vendored
View File

@@ -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/**

View File

@@ -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 !~~

View File

@@ -0,0 +1,87 @@
<?php
/**
* Git pre-commit hook to run PHP-CS-Fixer on staged PHP files which are not in third-party libs.
*/
$sRootFolderAbsPath = dirname(dirname(__DIR__));
$sPhpCsFixerBinaryAbsPath = $sRootFolderAbsPath.'/tests/php-code-style/vendor/bin/php-cs-fixer';
$sPhpCsFixerConfigFileAbsPath = $sRootFolderAbsPath.'/tests/php-code-style/.php-cs-fixer.dist.php';
// Retrieve list of staged (`--cached`) files (ACMR: Added, Changed, Modified, Renamed only)
$sStagedFilesCmd = 'git diff --cached --name-only --diff-filter=ACMR';
exec($sStagedFilesCmd, $aStagedFiles);
// Ignore non-PHP files and third-party folders
$aStagedFiles = array_filter($aStagedFiles, function ($sStagedFile) {
// Ignore non-PHP files
if (false === preg_match('/\.php$/i', $sStagedFile)) {
return false;
}
// Ignore files in third-party folders
$sNormalizedStagedFile = str_replace('\\', '/', $sStagedFile);
if (
strpos($sNormalizedStagedFile, 'lib/') !== false // Our root Composer libs folder
|| strpos($sNormalizedStagedFile, 'vendor/') !== false // Any sub-folder Composer libs folder
|| strpos($sNormalizedStagedFile, 'node_modules/') !== false // OUr root NPM libs folder
) {
return false;
}
return true;
});
$iStagedFilesCount = count($aStagedFiles);
if ($iStagedFilesCount === 0) {
echo "No file to check for PHP code style.\n";
exit(0);
}
echo "{$iStagedFilesCount} file(s) to check for PHP code style.\n";
// Prepare batch of files to process (limit command line length as it could fail on some systems)
$aChunks = array_chunk(array_values($aStagedFiles), 50);
$iExitCode = 0;
// Execute chunks
$iNbChunks = count($aChunks);
foreach ($aChunks as $iIdx => $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);

View File

@@ -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"
}

21
captainhook.json Normal file
View File

@@ -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
}
}
]
}
}

View File

@@ -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"
}

383
composer.lock generated
View File

@@ -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",

View File

@@ -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();

119
lib/bin/captainhook Normal file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../captainhook/captainhook/bin/captainhook)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->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';

5
lib/bin/captainhook.bat Normal file
View File

@@ -0,0 +1,5 @@
@ECHO OFF
setlocal DISABLEDELAYEDEXPANSION
SET BIN_TARGET=%~dp0/captainhook
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
php "%BIN_TARGET%" %*

View File

@@ -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.

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env php
<?php
/**
* CaptainHook
*
* Copyright (c) 2016, Sebastian Feldmann <sf@sebnastian-feldmann.info>
* 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);

View File

@@ -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"
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phive xmlns="https://phar.io/phive">
<phar name="phpunit" version="^9.4" installed="9.6.23" location="./tools/phpunit" copy="true"/>
<phar name="humbug/box" version="^4.0" installed="4.6.6" location="./tools/box" copy="true"/>
<phar name="phpcs" version="^3.5" installed="3.7.2" location="./tools/phpcs" copy="true"/>
<phar name="phpstan" version="^1.0" installed="1.12.28" location="./tools/phpstan" copy="true"/>
</phive>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<ruleset>
<arg name="cache" value=".cs-check.json"/>
<arg name="parallel" value="8" />
<rule ref="PSR12"/>
<file>src</file>
<file>tests</file>
</ruleset>

View File

@@ -0,0 +1,45 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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';
}

View File

@@ -0,0 +1,449 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, string>
*/
private array $settings;
/**
* All options related to running CaptainHook
*
* @var \CaptainHook\App\Config\Run
*/
private Run $runConfig;
/**
* List of users custom settings
*
* @var array<string, mixed>
*/
private array $custom = [];
/**
* List of plugins
*
* @var array<string, \CaptainHook\App\Config\Plugin>
*/
private array $plugins = [];
/**
* List of hook configs
*
* @var array<string, \CaptainHook\App\Config\Hook>
*/
private array $hooks = [];
/**
* Config constructor
*
* @param string $path
* @param bool $fileExists
* @param array<string, mixed> $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<string, mixed> $settings
* @return array<string, mixed>
*/
private function setupCustom(array $settings): array
{
/* @var array<string, mixed> $custom */
$this->custom = $settings['custom'] ?? [];
unset($settings['custom']);
return $settings;
}
/**
* Setup all configured plugins
*
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
private function setupPlugins(array $settings): array
{
/* @var array<int, array<string, mixed>> $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<string, mixed> $settings
* @return array<string, mixed>
*/
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<mixed>
*/
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<string, \CaptainHook\App\Config\Hook>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<int, mixed>
*/
private function getPluginsJsonData(): array
{
$plugins = [];
foreach ($this->plugins as $plugin) {
$plugins[] = $plugin->getJsonData();
}
return $plugins;
}
}

View File

@@ -0,0 +1,236 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, mixed>
*/
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<string, mixed> $options
* @param array<string, mixed> $conditions
* @param array<string, mixed> $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<string, mixed> $options
*/
private function setupOptions(array $options): void
{
$this->options = new Options($options);
}
/**
* Setup action conditions
*
* @param array<string, array<string, mixed>> $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<string, mixed> $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<string, mixed>
*/
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<int, mixed>
*/
private function getConditionJsonData(): array
{
$json = [];
foreach ($this->conditions as $condition) {
$json[] = $condition->getJsonData();
}
return $json;
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<mixed>
*/
private array $args;
/**
* Condition constructor
*
* @param string $exec
* @param array<mixed> $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<mixed>
*/
public function getArgs(): array
{
return $this->args;
}
/**
* Return config data
*
* @return array<string, mixed>
*/
public function getJsonData(): array
{
return [
'exec' => $this->exec,
'args' => $this->args,
];
}
}

View File

@@ -0,0 +1,347 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, mixed> $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<string, mixed> $settings
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $json
* @return array<string, mixed>
*/
private function extractSettings(array $json): array
{
return Util::extractListFromJson($json, 'config');
}
/**
* Returns the `conditions` section of an actionJson
*
* @param array<string, mixed> $json
* @return array<string, mixed>
*/
private function extractConditions(mixed $json): array
{
return Util::extractListFromJson($json, 'conditions');
}
/**
* Returns the `options` section af some json
*
* @param array<string, mixed> $json
* @return array<string, string>
*/
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<string, mixed> $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<int, string> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $settings
* @return \CaptainHook\App\Config
* @throws \Exception
*/
public static function create(string $path = '', array $settings = []): Config
{
$factory = new static();
return $factory->createConfig($path, $settings);
}
}

View File

@@ -0,0 +1,137 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, mixed>
*/
public function getJsonData(): array
{
$config = ['enabled' => $this->isEnabled, 'actions' => []];
foreach ($this->actions as $action) {
if (!$action->isIncluded()) {
$config['actions'][] = $action->getJsonData();
}
}
return $config;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 1.0.0
*/
class Options
{
/**
* Map of options
*
* @var array<string, mixed>
*/
private array $options;
/**
* Options constructor
*
* @param array<string, mixed> $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<string, mixed>
*/
public function getAll(): array
{
return $this->options;
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, mixed> $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<string, mixed> $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<string, mixed>
*/
public function getJsonData(): array
{
return [
'plugin' => $this->plugin,
'options' => $this->options->getAll(),
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, mixed> $options
*/
public function __construct(array $options = [])
{
$this->setupOptions($options);
}
/**
* Setup options
*
* @param array<string, mixed> $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<string, mixed>
*/
public function getJsonData(): array
{
return $this->options->getAll();
}
}

View File

@@ -0,0 +1,193 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 1.0.3
* @internal
*/
abstract class Util
{
/**
* Validate a configuration
*
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, array<string, mixed>> $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<string, mixed> $json
* @param string $value
* @return array<string, mixed>
*/
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<string, mixed> $settings
* @return array<string, mixed>
*/
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<string> $keys
* @param array<string, mixed> $subject
* @return bool
*/
private static function keysExist(array $keys, array $subject): bool
{
foreach ($keys as $key) {
if (!isset($subject[$key])) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,143 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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(
'<info>%s</info> version <comment>%s</comment> %s <fg=blue>#StandWith</><fg=yellow>Ukraine</>',
$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();
}
}

View File

@@ -0,0 +1,103 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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('<fg=red>' . $t->getMessage() . '</>');
if ($out->isVerbose()) {
$out->writeln(
'<comment>Error triggered in file:</comment> ' . $t->getFile() .
' <comment>in line:</comment> ' . $t->getLine()
);
}
return 1;
}
}

View File

@@ -0,0 +1,73 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}
}

View File

@@ -0,0 +1,129 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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<string> $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<string> $settingNames
* @return array<string, string>
*/
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);
}
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}
}

View File

@@ -0,0 +1,159 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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.');
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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');
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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');
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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');
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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');
}
}

View File

@@ -0,0 +1,108 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}
}

View File

@@ -0,0 +1,156 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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);
}
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <naderman@naderman.de>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @author Sebastian Feldmann <sf@sebastian-feldmann.info>
* @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<mixed>
*/
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<string> $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<string> $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);
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<mixed>
*/
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 [];
}
}

View File

@@ -0,0 +1,151 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, mixed>
*/
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);
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}

View File

@@ -0,0 +1,246 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string>
*/
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<int, int>
*/
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<string, mixed>
*/
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>|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;
}
}

View File

@@ -0,0 +1,88 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string>
*/
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<string> $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<string>
*/
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;
}
}

View File

@@ -0,0 +1,93 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 0.9.0
*/
abstract class IOUtil
{
public const PREFIX_OK = '<info>✔</info>';
public const PREFIX_FAIL = '<fg=red>✘</>';
/**
* Maps config values to Symfony verbosity values
*
* @var array<string, 16|32|64|128|256>
*/
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<string>|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<string>|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;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/**
* This file is part of captainhook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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';
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 5.11.0
*/
class Dispatcher
{
/**
* List of all registered handlers
*
* @var array<string, array<int, \CaptainHook\App\Event\Handler>>
*/
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<string, array<int, \CaptainHook\App\Event\Handler>> $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] ?? [];
}
}

View File

@@ -0,0 +1,97 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 5.11.0
*/
class HookFailed extends Hook
{
protected $name = 'onHookFailure';
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 5.11.0
*/
class HookSucceeded extends Hook
{
protected $name = 'onHookSuccess';
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 0.9.0
*/
class ActionFailed extends Exception implements CaptainHookException
{
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 5.9.0.
*/
interface CaptainHookException extends Throwable
{
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 0.9.0
*/
class InvalidHookName extends Exception implements CaptainHookException
{
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 5.9.0
*/
class InvalidPlugin extends RuntimeException implements CaptainHookException
{
}

View File

@@ -0,0 +1,62 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $filter
* @return array<string>
*/
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<string> $files
* @return void
*/
private static function displayFilesFound(IO $io, array $files): void
{
if ($io->isVerbose()) {
$io->write(' <comment>Changed files</comment>', true, IO::VERBOSE);
foreach ($files as $file) {
$io->write(' - ' . $file, true, IO::VERBOSE);
}
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $filter
* @return array<string>
*/
public function getChangedFiles(array $filter = []): array;
}

View File

@@ -0,0 +1,62 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $filter
* @return array<string>
*/
abstract public function getChangedFiles(array $filter = []): array;
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @link https://github.com/captainhook-git/captainhook
* @since Class available since Release 5.15.0
*/
class Factory
{
/**
* List of available range detectors
*
* @var array<string, string>
*/
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);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $filter
* @return array<string>
*/
public function getChangedFiles(array $filter = []): array
{
return $this->repository->getDiffOperator()->getChangedFiles(
$this->io->getArgument(Hooks::ARG_PREVIOUS_HEAD, 'HEAD@{1}'),
'HEAD',
$filter
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $filter
* @return array<string>
*/
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);
}
}

View File

@@ -0,0 +1,122 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $filter
* @return array<string>
*/
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<string> $filter
* @return array<string>
*/
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();
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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<int, string>
*/
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<int, string> $data
* @return array<int, string>
*/
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', '*']));
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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')
)
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $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;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string> $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;
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,68 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string, string>
*/
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),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,231 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string>
*/
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<string>
*/
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<string> $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;
}
}

View File

@@ -0,0 +1,105 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <fxedel@gmail.com>
* @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 : '<error>FAIL</error> 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 : '<info>OK</info> Branch name does match regex: %s';
}
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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
{
}
}

View File

@@ -0,0 +1,130 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string>
*/
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);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,81 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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<string>
*/
private array $options;
/**
* Constructor
*
* @param array<string> $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;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* This file is part of CaptainHook.
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}
}

View File

@@ -0,0 +1,66 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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();
}
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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;
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* This file is part of CaptainHook
*
* (c) Sebastian Feldmann <sf@sebastian-feldmann.info>
*
* 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 <sf@sebastian-feldmann.info>
* @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);
}
}

Some files were not shown because too many files have changed in this diff Show More