diff --git a/src/Console/Command/Hook.php b/src/Console/Command/Hook.php
index 1d49260b..0d1e7334 100644
--- a/src/Console/Command/Hook.php
+++ b/src/Console/Command/Hook.php
@@ -14,6 +14,7 @@
use CaptainHook\App\Config;
use CaptainHook\App\Console\IOUtil;
use CaptainHook\App\Hook\Util;
+use CaptainHook\App\Runner\Hook as RunnerHook;
use Exception;
use RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
@@ -56,6 +57,50 @@ protected function configure(): void
InputOption::VALUE_OPTIONAL,
'Relative path from your config file to your bootstrap file'
);
+
+ $this->addOption(
+ 'list-actions',
+ 'l',
+ InputOption::VALUE_NONE,
+ 'List actions for this hook without running the hook'
+ );
+
+ $this->addOption(
+ 'action',
+ 'a',
+ InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
+ 'Run only the actions listed'
+ );
+
+ $this->addOption(
+ 'disable-plugins',
+ null,
+ InputOption::VALUE_NONE,
+ 'Disable all hook plugins'
+ );
+ }
+
+ /**
+ * Initialize the command by checking/modifying inputs before validation
+ *
+ * @param \Symfony\Component\Console\Input\InputInterface $input
+ * @param \Symfony\Component\Console\Output\OutputInterface $output
+ * @return void
+ */
+ protected function initialize(InputInterface $input, OutputInterface $output): void
+ {
+ // If `--list-actions` is present, we will ignore any arguments, since
+ // this option intends to output a list of actions for the hook without
+ // running the hook. So, if any arguments are required but not present
+ // in the input, we will set them to an empty string in the input to
+ // suppress any validation errors.
+ if ($input->getOption('list-actions') === true) {
+ foreach ($this->getDefinition()->getArguments() as $arg) {
+ if ($arg->isRequired() && $input->getArgument($arg->getName()) === null) {
+ $input->setArgument($arg->getName(), '');
+ }
+ }
+ }
}
/**
@@ -74,8 +119,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
// use ansi coloring if not disabled in captainhook.json
$output->setDecorated($config->useAnsiColors());
- // use the configured verbosity to manage general output verbosity
- $output->setVerbosity(IOUtil::mapConfigVerbosity($config->getVerbosity()));
+
+ // If the verbose option is present on the command line, then use it.
+ // Otherwise, use the verbosity setting from the configuration.
+ if (!$input->hasOption('verbose') || !$input->getOption('verbose')) {
+ $output->setVerbosity(IOUtil::mapConfigVerbosity($config->getVerbosity()));
+ }
try {
$this->handleBootstrap($config);
@@ -83,6 +132,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$class = '\\CaptainHook\\App\\Runner\\Hook\\' . Util::getHookCommand($this->hookName);
/** @var \CaptainHook\App\Runner\Hook $hook */
$hook = new $class($io, $config, $repository);
+ // If list-actions is true, then list the hook actions instead of running them.
+ if ($input->getOption('list-actions') === true) {
+ $this->listActions($output, $hook);
+ return 0;
+ }
$hook->run();
return 0;
} catch (Exception $e) {
@@ -134,4 +188,30 @@ private function handleError(OutputInterface $output, Exception $e): int
return 1;
}
+
+ /**
+ * Print out a list of actions for this hook
+ *
+ * @param OutputInterface $output
+ * @param RunnerHook $hook
+ */
+ private function listActions(OutputInterface $output, RunnerHook $hook): void
+ {
+ $output->writeln('Listing ' . $hook->getName() . ' actions:');
+
+ if (!$hook->isEnabled()) {
+ $output->writeln(' - hook is disabled');
+ return;
+ }
+
+ $actions = $hook->getActions();
+ if (count($actions) === 0) {
+ $output->writeln(' - no actions configured');
+ return;
+ }
+
+ foreach ($actions as $action) {
+ $output->writeln(" - {$action->getAction()}>");
+ }
+ }
}
diff --git a/src/Console/IO/Base.php b/src/Console/IO/Base.php
index 500ac11a..7dc49e3e 100644
--- a/src/Console/IO/Base.php
+++ b/src/Console/IO/Base.php
@@ -45,6 +45,28 @@ public function getArgument(string $name, string $default = ''): string
return $default;
}
+ /**
+ * Return the original cli options
+ *
+ * @return array
+ */
+ public function getOptions(): array
+ {
+ return [];
+ }
+
+ /**
+ * Return the original cli option or a given default
+ *
+ * @param string $name
+ * @param string|string[]|bool|null $default
+ * @return string|string[]|bool|null
+ */
+ public function getOption(string $name, $default = null)
+ {
+ return $default;
+ }
+
/**
* Return the piped in standard input
*
diff --git a/src/Console/IO/DefaultIO.php b/src/Console/IO/DefaultIO.php
index 48f622c1..ca2104db 100644
--- a/src/Console/IO/DefaultIO.php
+++ b/src/Console/IO/DefaultIO.php
@@ -103,6 +103,28 @@ public function getArgument(string $name, string $default = ''): string
return (string)($this->getArguments()[$name] ?? $default);
}
+ /**
+ * Return the original cli options
+ *
+ * @return array
+ */
+ public function getOptions(): array
+ {
+ return $this->input->getOptions();
+ }
+
+ /**
+ * Return the original cli option or a given default
+ *
+ * @param string $name
+ * @param string|string[]|bool|null $default
+ * @return string|string[]|bool|null
+ */
+ public function getOption(string $name, $default = null)
+ {
+ return $this->getOptions()[$name] ?? $default;
+ }
+
/**
* Return the piped in standard input
*
diff --git a/src/Hook/File/Action/Check.php b/src/Hook/File/Action/Check.php
index 816c7b3c..ecfeb3bf 100644
--- a/src/Hook/File/Action/Check.php
+++ b/src/Hook/File/Action/Check.php
@@ -92,6 +92,7 @@ public function execute(Config $config, IO $io, Repository $repository, Config\A
* Setup the action, reading and validating all config settings
*
* @param \CaptainHook\App\Config\Options $options
+ * @codeCoverageIgnore
*/
protected function setUp(Config\Options $options): void
{
diff --git a/src/Plugin/Hook/DisallowActionChanges.php b/src/Plugin/Hook/DisallowActionChanges.php
new file mode 100644
index 00000000..ab069574
--- /dev/null
+++ b/src/Plugin/Hook/DisallowActionChanges.php
@@ -0,0 +1,81 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace CaptainHook\App\Plugin\Hook;
+
+use CaptainHook\App\Config;
+use CaptainHook\App\Plugin;
+use CaptainHook\App\Plugin\Hook\Exception\ActionChangedFiles;
+use CaptainHook\App\Runner\Hook as RunnerHook;
+use SebastianFeldmann\Git\Diff\File;
+
+/**
+ * DisallowActionChanges runner plugin
+ *
+ * @package CaptainHook
+ * @author Sebastian Feldmann
+ * @link https://github.com/captainhookphp/captainhook
+ * @since Class available since Release 5.11.0.
+ */
+class DisallowActionChanges extends PreserveWorkingTree implements Plugin\Hook
+{
+ /**
+ * @var iterable
+ */
+ private $priorDiff = [];
+
+ /**
+ * An array of actions that made changes to files. Each action name is the
+ * key for an array of file changes made by that action.
+ *
+ * @var array
+ */
+ private $actionChanges = [];
+
+ public function beforeHook(RunnerHook $hook): void
+ {
+ parent::beforeHook($hook);
+
+ // Get a diff of the current state of the working tree. Since we ran
+ // the parent beforeHook(), which moves changes out of the working
+ // tree, this should be an empty diff.
+ $this->priorDiff = $this->repository->getDiffOperator()->compareTo();
+ }
+
+ public function afterAction(RunnerHook $hook, Config\Action $action): void
+ {
+ $afterDiff = $this->repository->getDiffOperator()->compareTo();
+
+ // Did this action make any changes?
+ if ($afterDiff != $this->priorDiff) {
+ $this->actionChanges[$action->getAction()] = $afterDiff;
+ }
+
+ $this->priorDiff = $afterDiff;
+ }
+
+ public function afterHook(RunnerHook $hook): void
+ {
+ parent::afterHook($hook);
+
+ if (count($this->actionChanges) > 0) {
+ $message = '';
+ foreach ($this->actionChanges as $action => $changes) {
+ $message .= 'Action \'' . $action
+ . '\' on hook ' . $hook->getName()
+ . ' modified files'
+ . PHP_EOL;
+ }
+
+ throw new ActionChangedFiles($message);
+ }
+ }
+}
diff --git a/src/Plugin/Hook/Example.php b/src/Plugin/Hook/Example.php
new file mode 100644
index 00000000..6bb08710
--- /dev/null
+++ b/src/Plugin/Hook/Example.php
@@ -0,0 +1,56 @@
+io->write(['Plugin ' . self::class . '::beforeHook()>', '']);
+ }
+
+ /**
+ * Runs before each action.
+ *
+ * @param RunnerHook $hook This is the current hook that's running.
+ * @param Config\Action $action This is the configuration for action that will
+ * run immediately following this method.
+ */
+ public function beforeAction(RunnerHook $hook, Config\Action $action): void
+ {
+ $this->io->write(['', ' Plugin ' . self::class . '::beforeAction()>']);
+ }
+
+ /**
+ * Runs after each action.
+ *
+ * @param RunnerHook $hook This is the current hook that's running.
+ * @param Config\Action $action This is the configuration for action that just
+ * ran immediately before this method.
+ */
+ public function afterAction(RunnerHook $hook, Config\Action $action): void
+ {
+ $this->io->write(['', ' Plugin ' . self::class . '::afterAction()>', '']);
+ }
+
+ /**
+ * Runs after the hook.
+ *
+ * @param RunnerHook $hook This is the current hook that's running.
+ */
+ public function afterHook(RunnerHook $hook): void
+ {
+ $this->io->write(['', 'Plugin ' . self::class . '::afterHook()>']);
+ }
+}
diff --git a/src/Plugin/Hook/Exception/ActionChangedFiles.php b/src/Plugin/Hook/Exception/ActionChangedFiles.php
new file mode 100644
index 00000000..4667e30a
--- /dev/null
+++ b/src/Plugin/Hook/Exception/ActionChangedFiles.php
@@ -0,0 +1,27 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace CaptainHook\App\Plugin\Hook\Exception;
+
+use CaptainHook\App\Exception\CaptainHookException;
+use RuntimeException;
+
+/**
+ * Class ActionChangedFiles
+ *
+ * @package CaptainHook
+ * @author Sebastian Feldmann
+ * @link https://github.com/captainhookphp/captainhook
+ * @since Class available since Release 5.11.0.
+ */
+class ActionChangedFiles extends RuntimeException implements CaptainHookException
+{
+}
diff --git a/src/Runner/Hook.php b/src/Runner/Hook.php
index b4c75594..98097d02 100644
--- a/src/Runner/Hook.php
+++ b/src/Runner/Hook.php
@@ -114,12 +114,19 @@ public function run(): void
{
$hookConfigs = $this->getHookConfigsToHandle();
- $this->io->write('' . $this->hook . ': ');
+ /** @var IO\Base $io */
+ $io = $this->io;
+
+ $io->write('' . $this->hook . ': ');
+
+ if ($io->getOption('disable-plugins')) {
+ $io->write('Running with plugins disabled>');
+ }
// if the hook and all triggered virtual hooks
// are NOT enabled in the captainhook configuration skip the execution
if (!$this->isAnyConfigEnabled($hookConfigs)) {
- $this->io->write(' - hook is disabled');
+ $io->write(' - hook is disabled');
return;
}
@@ -128,7 +135,7 @@ public function run(): void
$actions = $this->getActionsToExecute($hookConfigs);
// are any actions configured
if (count($actions) === 0) {
- $this->io->write(' - no actions to execute', true);
+ $io->write(' - no actions to execute', true);
} else {
$this->executeActions($actions);
}
@@ -153,6 +160,16 @@ public function getHookConfigsToHandle(): array
return $configs;
}
+ /**
+ * Returns true if this hook or any applicable virtual hooks are enabled
+ *
+ * @return bool
+ */
+ public function isEnabled(): bool
+ {
+ return $this->isAnyConfigEnabled($this->getHookConfigsToHandle());
+ }
+
/**
* @param \CaptainHook\App\Config\Hook[] $configs
* @return bool
@@ -194,6 +211,16 @@ public function shouldSkipActions(?bool $shouldSkip = null): bool
return $this->skipActions;
}
+ /**
+ * Returns all actions configured for this hook or applicable virtual hooks
+ *
+ * @return \CaptainHook\App\Config\Action[]
+ */
+ public function getActions(): array
+ {
+ return $this->getActionsToExecute($this->getHookConfigsToHandle());
+ }
+
/**
* Return all the actions to execute
*
@@ -283,18 +310,9 @@ private function executeFailAfterAllActions(array $actions): void
*/
private function handleAction(Config\Action $action): void
{
- if ($this->shouldSkipActions()) {
- $this->io->write(
- $this->formatActionOutput($action->getAction()) . ': deactivated',
- true
- );
- return;
- }
-
$this->io->write(' - ' . $this->formatActionOutput($action->getAction()) . '> : ', false);
- if (!$this->doConditionsApply($action->getConditions())) {
- $this->io->write('skipped', true);
+ if ($this->checkSkipAction($action)) {
return;
}
@@ -303,6 +321,7 @@ private function handleAction(Config\Action $action): void
// The beforeAction() method may indicate that the current and all
// remaining actions should be skipped. If so, return here.
if ($this->shouldSkipActions()) {
+ $this->io->write('deactivated', true);
return;
}
@@ -360,6 +379,55 @@ public static function getExecMethod(string $type): string
return $valid[$type];
}
+ /**
+ * Check if the action should be skipped
+ *
+ * @param Config\Action $action
+ * @return bool
+ */
+ private function checkSkipAction(Config\Action $action): bool
+ {
+ if (
+ $this->shouldSkipActions()
+ || $this->cliSkipAction($action)
+ || !$this->doConditionsApply($action->getConditions())
+ ) {
+ $this->io->write('skipped', true);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the CLI `action` options indicates the action should be skipped
+ *
+ * @param Config\Action $action
+ * @return bool
+ */
+ private function cliSkipAction(Config\Action $action): bool
+ {
+ /** @var IO\Base $io */
+ $io = $this->io;
+
+ /** @var string[] $actionsToRun */
+ $actionsToRun = $io->getOption('action', []);
+
+ if (empty($actionsToRun)) {
+ // No actions specified on CLI; run all actions.
+ return false;
+ }
+
+ if (in_array($action->getAction(), $actionsToRun)) {
+ // Action specified on CLI; do not skip.
+ return false;
+ }
+
+ // Action not specified on CLI; skip.
+ return true;
+ }
+
/**
* Check if conditions apply
*
@@ -413,7 +481,7 @@ private function getHookPlugins(): array
}
$this->io->write(
- ['', 'Configuring Hook Plugin: ' . $pluginClass . ''],
+ ['Configured Hook Plugin: ' . $pluginClass . ''],
true,
IO::VERBOSE
);
@@ -449,10 +517,17 @@ private function getHookPlugins(): array
*/
private function executeHookPluginsFor(string $method, ?Config\Action $action = null): void
{
+ /** @var IO\Base $io */
+ $io = $this->io;
+
+ if ($io->getOption('disable-plugins')) {
+ return;
+ }
+
$plugins = $this->getHookPlugins();
if (count($plugins) === 0) {
- $this->io->write(['', 'No plugins to execute for: ' . $method . ''], true, IO::DEBUG);
+ $io->write(['', 'No plugins to execute for: ' . $method . ''], true, IO::DEBUG);
return;
}
@@ -463,10 +538,10 @@ private function executeHookPluginsFor(string $method, ?Config\Action $action =
$params[] = $action;
}
- $this->io->write(['', 'Executing plugins for: ' . $method . ''], true, IO::DEBUG);
+ $io->write(['', 'Executing plugins for: ' . $method . ''], true, IO::DEBUG);
foreach ($plugins as $plugin) {
- $this->io->write('- Running ' . get_class($plugin) . '::' . $method . '', true, IO::DEBUG);
+ $io->write(' - Running ' . get_class($plugin) . '::' . $method . '', true, IO::DEBUG);
$plugin->{$method}(...$params);
}
}
diff --git a/tests/files/config/valid-with-all-disabled-hooks.json b/tests/files/config/valid-with-all-disabled-hooks.json
new file mode 100644
index 00000000..2c63c085
--- /dev/null
+++ b/tests/files/config/valid-with-all-disabled-hooks.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/unit/Console/Command/Hook/PreCommitTest.php b/tests/unit/Console/Command/Hook/PreCommitTest.php
index df771fc0..c44427a5 100644
--- a/tests/unit/Console/Command/Hook/PreCommitTest.php
+++ b/tests/unit/Console/Command/Hook/PreCommitTest.php
@@ -11,12 +11,14 @@
namespace CaptainHook\App\Console\Command\Hook;
+use CaptainHook\App\Console\Command;
use CaptainHook\App\Console\Runtime\Resolver;
use CaptainHook\App\Git\DummyRepo;
use Exception;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Output\Output;
class PreCommitTest extends TestCase
{
@@ -145,4 +147,63 @@ public function testExecuteFailingActionInVerboseMode(): void
$cmd = new PreCommit($resolver);
$this->assertEquals(1, $cmd->run($input, $output));
}
+
+ public function testListActionsPrintsActions(): void
+ {
+ $output = $this->getMockBuilder(Output::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $output
+ ->expects($this->exactly(3))
+ ->method('writeln')
+ ->withConsecutive(
+ ['Listing pre-commit actions:'],
+ [' - phpcs --standard=psr2 src>'],
+ [' - phpunit --configuration=build/phpunit-hook.xml>']
+ );
+
+ $repo = new DummyRepo();
+ $input = new ArrayInput(
+ [
+ '--configuration' => CH_PATH_FILES . '/config/valid-with-includes.json',
+ '--git-directory' => $repo->getGitDir(),
+ '--list-actions' => true,
+ ]
+ );
+
+ $cmd = new PreCommit(new Resolver());
+ $cmd->run($input, $output);
+
+ $this->assertTrue(true);
+ }
+
+ public function testListActionsForDisabledHook(): void
+ {
+ $output = $this->getMockBuilder(Output::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $output
+ ->expects($this->exactly(2))
+ ->method('writeln')
+ ->withConsecutive(
+ ['Listing pre-commit actions:'],
+ [' - hook is disabled']
+ );
+
+ $repo = new DummyRepo();
+ $input = new ArrayInput(
+ [
+ '--configuration' => CH_PATH_FILES . '/config/valid-with-all-disabled-hooks.json',
+ '--git-directory' => $repo->getGitDir(),
+ '--list-actions' => true,
+ ]
+ );
+
+ $cmd = new PreCommit(new Resolver());
+ $cmd->run($input, $output);
+
+ $this->assertTrue(true);
+ }
}
diff --git a/tests/unit/Console/Command/Hook/PrepareCommitMsgTest.php b/tests/unit/Console/Command/Hook/PrepareCommitMsgTest.php
index 216f84c4..e9a34f51 100644
--- a/tests/unit/Console/Command/Hook/PrepareCommitMsgTest.php
+++ b/tests/unit/Console/Command/Hook/PrepareCommitMsgTest.php
@@ -11,11 +11,14 @@
namespace CaptainHook\App\Console\Command\Hook;
+use CaptainHook\App\Console\Command;
+use CaptainHook\App\Console\IO\DefaultIO;
use CaptainHook\App\Console\Runtime\Resolver;
use CaptainHook\App\Git\DummyRepo;
use Symfony\Component\Console\Input\ArrayInput;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Output\NullOutput;
+use Symfony\Component\Console\Output\Output;
class PrepareCommitMsgTest extends TestCase
{
@@ -42,4 +45,33 @@ public function testExecute(): void
$this->assertTrue(true);
}
+
+ public function testListActionsDisablesRequiredArguments(): void
+ {
+ $output = $this->getMockBuilder(Output::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $output
+ ->expects($this->exactly(2))
+ ->method('writeln')
+ ->withConsecutive(
+ ['Listing prepare-commit-msg actions:'],
+ [' - no actions configured']
+ );
+
+ $repo = new DummyRepo();
+ $input = new ArrayInput(
+ [
+ '--configuration' => CH_PATH_FILES . '/config/valid.json',
+ '--git-directory' => $repo->getGitDir(),
+ '--list-actions' => true,
+ ]
+ );
+
+ $cmd = new PrepareCommitMsg(new Resolver());
+ $cmd->run($input, $output);
+
+ $this->assertTrue(true);
+ }
}
diff --git a/tests/unit/Console/IO/DefaultIOTest.php b/tests/unit/Console/IO/DefaultIOTest.php
index c3a77026..7492150b 100644
--- a/tests/unit/Console/IO/DefaultIOTest.php
+++ b/tests/unit/Console/IO/DefaultIOTest.php
@@ -104,6 +104,36 @@ public function testGetArgument(): void
$this->assertEquals('bar', $io->getArgument('fiz', 'bar'));
}
+ /**
+ * Tests DefaultIO::getOptions
+ */
+ public function testGetOptions(): void
+ {
+ $input = $this->getInputMock();
+ $output = $this->getOutputMock();
+ $helper = $this->getHelperSetMock();
+
+ $input->expects($this->once())->method('getOptions')->willReturn(['foo' => 'bar']);
+ $io = new DefaultIO($this->fakeStdIn(), $input, $output, $helper);
+
+ $this->assertEquals(['foo' => 'bar'], $io->getOptions());
+ }
+
+ /**
+ * Tests DefaultIO::getOption
+ */
+ public function testGetOption(): void
+ {
+ $input = $this->getInputMock();
+ $output = $this->getOutputMock();
+ $helper = $this->getHelperSetMock();
+
+ $input->expects($this->exactly(2))->method('getOptions')->willReturn(['foo' => 'bar']);
+ $io = new DefaultIO($this->fakeStdIn(), $input, $output, $helper);
+
+ $this->assertEquals('bar', $io->getOption('foo'));
+ $this->assertEquals('bar', $io->getOption('fiz', 'bar'));
+ }
/**
* Tests DefaultIO::getStandardInput
diff --git a/tests/unit/Console/IO/NullIOTest.php b/tests/unit/Console/IO/NullIOTest.php
index fa0dece9..20f0f591 100644
--- a/tests/unit/Console/IO/NullIOTest.php
+++ b/tests/unit/Console/IO/NullIOTest.php
@@ -34,6 +34,25 @@ public function testGetArgument(): void
$this->assertEquals('bar', $io->getArgument('foo', 'bar'));
}
+ /**
+ * Tests NullIO::getOptions
+ */
+ public function testGetOptions(): void
+ {
+ $io = new NullIO();
+ $this->assertEquals([], $io->getOptions());
+ }
+
+ /**
+ * Tests NullIO::getOption
+ */
+ public function testGetOption(): void
+ {
+ $io = new NullIO();
+ $this->assertEquals('', $io->getOption('foo'));
+ $this->assertEquals('bar', $io->getOption('foo', 'bar'));
+ }
+
/**
* Tests NullIO::getStandardInput
*/
diff --git a/tests/unit/Runner/Hook/PreCommitTest.php b/tests/unit/Runner/Hook/PreCommitTest.php
index 815efe1f..19648aac 100644
--- a/tests/unit/Runner/Hook/PreCommitTest.php
+++ b/tests/unit/Runner/Hook/PreCommitTest.php
@@ -82,12 +82,18 @@ public function testRunHookDontFailOnFirstError(): void
// so even if the first actions fails this action has to get executed
$actionConfigSuccess->expects($this->atLeastOnce())
->method('getAction')
- ->willReturn(CH_PATH_FILES . '/bin/failure');
+ ->willReturn(CH_PATH_FILES . '/bin/success');
+
+ $actionConfigWithReallyLongName = $this->createActionConfigMock();
+ $actionConfigWithReallyLongName
+ ->expects($this->atLeastOnce())
+ ->method('getAction')
+ ->willReturn(CH_PATH_FILES . '/bin/success --really-long-option-name-to-ensure-this-is-over-65-characters');
$hookConfig->expects($this->atLeast(1))->method('isEnabled')->willReturn(true);
$hookConfig->expects($this->once())
->method('getActions')
- ->willReturn([$actionConfigFail, $actionConfigSuccess]);
+ ->willReturn([$actionConfigFail, $actionConfigSuccess, $actionConfigWithReallyLongName]);
$config->expects($this->once())->method('getHookConfig')->willReturn($hookConfig);
$io->expects($this->atLeast(1))->method('write');
diff --git a/tests/unit/Runner/HookTest.php b/tests/unit/Runner/HookTest.php
index 1a099d4a..7bf132d0 100644
--- a/tests/unit/Runner/HookTest.php
+++ b/tests/unit/Runner/HookTest.php
@@ -13,6 +13,7 @@
use CaptainHook\App\Config;
use CaptainHook\App\Config\Mockery as ConfigMockery;
+use CaptainHook\App\Console\IO;
use CaptainHook\App\Console\IO\Mockery as IOMockery;
use CaptainHook\App\Hook\Restriction;
use CaptainHook\App\Hooks;
@@ -24,6 +25,7 @@
use CaptainHook\App\Plugin\DummyHookPlugin;
use CaptainHook\App\Plugin\DummyHookPluginSkipsActions;
use Exception;
+use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class HookTest extends TestCase
@@ -262,4 +264,352 @@ public function testRunHookSkipsActionsFromPluginBeforeAction(): void
$this->assertSame(2, DummyHookPlugin::$afterActionCalled);
$this->assertSame(1, DummyHookPlugin::$afterHookCalled);
}
+
+ public function testGetActionsWithoutVirtualHooks(): void
+ {
+ $actionConfig1 = $this->createActionConfigMock();
+ $actionConfig1->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --foo');
+
+ $actionConfig2 = $this->createActionConfigMock();
+ $actionConfig2->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --bar');
+
+ $hookConfig = $this->createHookConfigMock();
+ $hookConfig->expects($this->atLeast(1))->method('isEnabled')->willReturn(true);
+ $hookConfig->expects($this->once())->method('getActions')->willReturn([$actionConfig1, $actionConfig2]);
+
+ $config = $this->createConfigMock();
+ $config->expects($this->once())->method('getHookConfig')->with('pre-commit')->willReturn($hookConfig);
+
+ $io = $this->createIOMock();
+ $repo = $this->createRepositoryMock();
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::PRE_COMMIT;
+ };
+
+ $this->assertSame([$actionConfig1, $actionConfig2], $runner->getActions());
+ }
+
+ public function testGetActionsWithVirtualHooks(): void
+ {
+ $actionConfig1 = $this->createActionConfigMock();
+ $actionConfig1->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --foo');
+
+ $actionConfig2 = $this->createActionConfigMock();
+ $actionConfig2->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --bar');
+
+ $actionConfig3 = $this->createActionConfigMock();
+ $actionConfig3->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --baz');
+
+ $hookConfig1 = $this->createHookConfigMock();
+ $hookConfig1->expects($this->exactly(2))->method('getName')->willReturn('post-checkout');
+ $hookConfig1->expects($this->atLeast(1))->method('isEnabled')->willReturn(true);
+ $hookConfig1->expects($this->once())->method('getActions')->willReturn([$actionConfig1, $actionConfig2]);
+
+ $hookConfig2 = $this->createHookConfigMock();
+ $hookConfig2->expects($this->atLeast(1))->method('isEnabled')->willReturn(true);
+ $hookConfig2->expects($this->once())->method('getActions')->willReturn([$actionConfig3]);
+
+ $config = $this->createConfigMock();
+ $config
+ ->expects($this->exactly(2))
+ ->method('getHookConfig')
+ ->withConsecutive(['post-checkout'], ['post-change'])
+ ->willReturn($hookConfig1, $hookConfig2);
+
+ $io = $this->createIOMock();
+ $repo = $this->createRepositoryMock();
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::POST_CHECKOUT;
+ };
+
+ $this->assertSame([$actionConfig1, $actionConfig2, $actionConfig3], $runner->getActions());
+ }
+
+ public function testGetActionsReturnsEmptyArrayWhenNoConfigsAreEnabled(): void
+ {
+ $actionConfig1 = $this->createActionConfigMock();
+ $actionConfig1->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --foo');
+
+ $actionConfig2 = $this->createActionConfigMock();
+ $actionConfig2->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --bar');
+
+ $actionConfig3 = $this->createActionConfigMock();
+ $actionConfig3->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --baz');
+
+ $hookConfig1 = $this->createHookConfigMock();
+ $hookConfig1->expects($this->exactly(2))->method('getName')->willReturn('post-checkout');
+ $hookConfig1->expects($this->atLeast(1))->method('isEnabled')->willReturn(false);
+
+ $hookConfig2 = $this->createHookConfigMock();
+ $hookConfig2->expects($this->atLeast(1))->method('isEnabled')->willReturn(false);
+
+ $config = $this->createConfigMock();
+ $config
+ ->expects($this->exactly(2))
+ ->method('getHookConfig')
+ ->withConsecutive(['post-checkout'], ['post-change'])
+ ->willReturn($hookConfig1, $hookConfig2);
+
+ $io = $this->createIOMock();
+ $repo = $this->createRepositoryMock();
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::POST_CHECKOUT;
+ };
+
+ $this->assertSame([], $runner->getActions());
+ }
+
+ public function testGetActionReturnsOnlyConfigsThatAreEnabled(): void
+ {
+ $actionConfig1 = $this->createActionConfigMock();
+ $actionConfig1->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --foo');
+
+ $actionConfig2 = $this->createActionConfigMock();
+ $actionConfig2->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --bar');
+
+ $actionConfig3 = $this->createActionConfigMock();
+ $actionConfig3->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --baz');
+
+ $hookConfig1 = $this->createHookConfigMock();
+ $hookConfig1->expects($this->exactly(2))->method('getName')->willReturn('post-checkout');
+ $hookConfig1->expects($this->atLeast(1))->method('isEnabled')->willReturn(false);
+
+ $hookConfig2 = $this->createHookConfigMock();
+ $hookConfig2->expects($this->atLeast(1))->method('isEnabled')->willReturn(true);
+ $hookConfig2->expects($this->once())->method('getActions')->willReturn([$actionConfig3]);
+
+ $config = $this->createConfigMock();
+ $config
+ ->expects($this->exactly(2))
+ ->method('getHookConfig')
+ ->withConsecutive(['post-checkout'], ['post-change'])
+ ->willReturn($hookConfig1, $hookConfig2);
+
+ $io = $this->createIOMock();
+ $repo = $this->createRepositoryMock();
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::POST_CHECKOUT;
+ };
+
+ $this->assertSame([$actionConfig3], $runner->getActions());
+ }
+
+ public function testIsEnabledReturnsFalseWhenNoConfigsAreEnabled(): void
+ {
+ $actionConfig1 = $this->createActionConfigMock();
+ $actionConfig1->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --foo');
+
+ $actionConfig2 = $this->createActionConfigMock();
+ $actionConfig2->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --bar');
+
+ $actionConfig3 = $this->createActionConfigMock();
+ $actionConfig3->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --baz');
+
+ $hookConfig1 = $this->createHookConfigMock();
+ $hookConfig1->expects($this->exactly(2))->method('getName')->willReturn('post-checkout');
+ $hookConfig1->expects($this->atLeast(1))->method('isEnabled')->willReturn(false);
+
+ $hookConfig2 = $this->createHookConfigMock();
+ $hookConfig2->expects($this->atLeast(1))->method('isEnabled')->willReturn(false);
+
+ $config = $this->createConfigMock();
+ $config
+ ->expects($this->exactly(2))
+ ->method('getHookConfig')
+ ->withConsecutive(['post-checkout'], ['post-change'])
+ ->willReturn($hookConfig1, $hookConfig2);
+
+ $io = $this->createIOMock();
+ $repo = $this->createRepositoryMock();
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::POST_CHECKOUT;
+ };
+
+ $this->assertFalse($runner->isEnabled());
+ }
+
+ public function testIsEnabledReturnsTrueWhenAtLeastOneConfigIsEnabled(): void
+ {
+ $actionConfig1 = $this->createActionConfigMock();
+ $actionConfig1->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --foo');
+
+ $actionConfig2 = $this->createActionConfigMock();
+ $actionConfig2->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --bar');
+
+ $actionConfig3 = $this->createActionConfigMock();
+ $actionConfig3->method('getAction')->willReturn(CH_PATH_FILES . '/bin/success --baz');
+
+ $hookConfig1 = $this->createHookConfigMock();
+ $hookConfig1->expects($this->exactly(2))->method('getName')->willReturn('post-checkout');
+ $hookConfig1->expects($this->atLeast(1))->method('isEnabled')->willReturn(false);
+
+ $hookConfig2 = $this->createHookConfigMock();
+ $hookConfig2->expects($this->atLeast(1))->method('isEnabled')->willReturn(true);
+
+ $config = $this->createConfigMock();
+ $config
+ ->expects($this->exactly(2))
+ ->method('getHookConfig')
+ ->withConsecutive(['post-checkout'], ['post-change'])
+ ->willReturn($hookConfig1, $hookConfig2);
+
+ $io = $this->createIOMock();
+ $repo = $this->createRepositoryMock();
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::POST_CHECKOUT;
+ };
+
+ $this->assertTrue($runner->isEnabled());
+ }
+
+ public function testRunHookWhenPluginsAreDisabled(): void
+ {
+ $successProgram = CH_PATH_FILES . '/bin/success';
+
+ $pluginConfig1 = new Config\Plugin(DummyHookPlugin::class);
+ $pluginConfig2 = new Config\Plugin(DummyHookPlugin::class);
+
+ $config = $this->createConfigMock();
+ $config->method('failOnFirstError')->willReturn(false);
+ $config->method('getPlugins')->willReturn([$pluginConfig1, $pluginConfig2]);
+
+ $optionCallback = function (string $option) {
+ switch ($option) {
+ case 'disable-plugins':
+ return true;
+ case 'action':
+ return [];
+ default:
+ throw new InvalidArgumentException('Received invalid option: ' . $option);
+ }
+ };
+
+ $io = $this->createIOMock();
+ $repo = $this->createRepositoryMock();
+ $hookConfig = $this->createHookConfigMock();
+ $actionConfig = $this->createActionConfigMock();
+ $actionConfig->expects($this->atLeastOnce())->method('getAction')->willReturn($successProgram);
+ $hookConfig->expects($this->atLeastOnce())->method('isEnabled')->willReturn(true);
+ $hookConfig->expects($this->once())->method('getActions')->willReturn([$actionConfig, clone $actionConfig]);
+ $config->expects($this->once())->method('getHookConfig')->willReturn($hookConfig);
+
+ $io
+ ->method('getOption')
+ ->with($this->logicalOr(
+ $this->equalTo('disable-plugins'),
+ $this->equalTo('action')
+ ))
+ ->willReturn($this->returnCallback($optionCallback));
+
+ $io
+ ->expects($this->exactly(8))
+ ->method('write')
+ ->withConsecutive(
+ ['pre-commit: '],
+ ['Running with plugins disabled>'],
+ [' - ' . $this->formatActionOutput($successProgram) . '> : ', false],
+ [['', 'foo', ''], true, IO::VERBOSE],
+ ['done'],
+ [' - ' . $this->formatActionOutput($successProgram) . '> : ', false],
+ [['', 'foo', ''], true, IO::VERBOSE],
+ ['done']
+ );
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::PRE_COMMIT;
+ };
+ $runner->run();
+
+ $this->assertSame(0, DummyHookPlugin::$beforeHookCalled);
+ $this->assertSame(0, DummyHookPlugin::$beforeActionCalled);
+ $this->assertSame(0, DummyHookPlugin::$afterActionCalled);
+ $this->assertSame(0, DummyHookPlugin::$afterHookCalled);
+ }
+
+ public function testRunHookWhenActionsSpecifiedOnCli(): void
+ {
+ $successProgram = CH_PATH_FILES . '/bin/success';
+ $failureProgram = CH_PATH_FILES . '/bin/failure';
+
+ $optionCallback = function (string $option) {
+ switch ($option) {
+ case 'disable-plugins':
+ return true;
+ case 'action':
+ return [CH_PATH_FILES . '/bin/success'];
+ default:
+ throw new InvalidArgumentException('Received invalid option: ' . $option);
+ }
+ };
+
+ $repo = $this->createRepositoryMock();
+
+ $actionSuccessConfig = $this->createActionConfigMock();
+ $actionSuccessConfig
+ ->expects($this->atLeastOnce())
+ ->method('getAction')
+ ->willReturn($successProgram);
+
+ $actionFailureConfig = $this->createActionConfigMock();
+ $actionFailureConfig
+ ->expects($this->atLeastOnce())
+ ->method('getAction')
+ ->willReturn($failureProgram);
+
+ $hookConfig = $this->createHookConfigMock();
+ $hookConfig->expects($this->atLeastOnce())->method('isEnabled')->willReturn(true);
+ $hookConfig
+ ->expects($this->once())
+ ->method('getActions')
+ ->willReturn([$actionSuccessConfig, $actionFailureConfig]);
+
+ $config = $this->createConfigMock();
+ $config->method('failOnFirstError')->willReturn(false);
+ $config->method('getPlugins')->willReturn([]);
+ $config->expects($this->once())->method('getHookConfig')->willReturn($hookConfig);
+
+ $io = $this->createIOMock();
+
+ $io
+ ->method('getOption')
+ ->with($this->logicalOr(
+ $this->equalTo('disable-plugins'),
+ $this->equalTo('action')
+ ))
+ ->willReturn($this->returnCallback($optionCallback));
+
+ $io
+ ->expects($this->exactly(7))
+ ->method('write')
+ ->withConsecutive(
+ ['pre-commit: '],
+ ['Running with plugins disabled>'],
+ [' - ' . $this->formatActionOutput($successProgram) . '> : ', false],
+ [['', 'foo', ''], true, IO::VERBOSE],
+ ['done'],
+ [' - ' . $this->formatActionOutput($failureProgram) . '> : ', false],
+ ['skipped']
+ );
+
+ $runner = new class ($io, $config, $repo) extends Hook {
+ protected $hook = Hooks::PRE_COMMIT;
+ };
+ $runner->run();
+ }
+
+ private function formatActionOutput(string $action): string
+ {
+ $actionLength = 65;
+ if (mb_strlen($action) < $actionLength) {
+ return str_pad($action, $actionLength, ' ');
+ }
+
+ return mb_substr($action, 0, $actionLength - 3) . '...';
+ }
}