Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.41% covered (success)
98.41%
124 / 126
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitHooksCommand
98.41% covered (success)
98.41%
124 / 126
75.00% covered (warning)
75.00%
3 / 4
25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
1
 execute
97.73% covered (success)
97.73%
86 / 88
0.00% covered (danger)
0.00%
0 / 1
22
 shouldReplaceHook
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Command;
21
22use Composer\Command\BaseCommand;
23use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\Filesystem\FinderFactoryInterface;
26use FastForward\DevTools\Filesystem\FilesystemInterface;
27use FastForward\DevTools\Resource\FileDiffer;
28use Psr\Log\LoggerInterface;
29use Symfony\Component\Config\FileLocatorInterface;
30use Symfony\Component\Console\Attribute\AsCommand;
31use Symfony\Component\Console\Input\InputInterface;
32use Symfony\Component\Console\Input\InputOption;
33use Symfony\Component\Console\Output\OutputInterface;
34use Symfony\Component\Filesystem\Path;
35
36/**
37 * Installs packaged Git hooks for the consumer repository.
38 */
39#[AsCommand(
40    name: 'git-hooks',
41    description: 'Installs Fast Forward Git hooks.',
42    help: 'This command copies packaged Git hooks into the current repository.'
43)]
44 class GitHooksCommand extends BaseCommand implements LoggerAwareCommandInterface
45{
46    use HasJsonOption;
47    use LogsCommandResults;
48
49    /**
50     * Creates a new GitHooksCommand instance.
51     *
52     * @param FilesystemInterface $filesystem the filesystem used to copy hooks
53     * @param FileLocatorInterface $fileLocator the locator used to find packaged hooks
54     * @param FinderFactoryInterface $finderFactory the factory used to create finders for hook files
55     * @param FileDiffer $fileDiffer
56     * @param LoggerInterface $logger the output-aware logger
57     */
58    public function __construct(
59        private  FilesystemInterface $filesystem,
60        private  FileLocatorInterface $fileLocator,
61        private  FinderFactoryInterface $finderFactory,
62        private  FileDiffer $fileDiffer,
63        private  LoggerInterface $logger,
64    ) {
65        parent::__construct();
66    }
67
68    /**
69     * Configures hook source, target, and initialization options.
70     */
71    protected function configure(): void
72    {
73        $this->addJsonOption()
74            ->addOption(
75                name: 'source',
76                shortcut: 's',
77                mode: InputOption::VALUE_OPTIONAL,
78                description: 'Path to the packaged Git hooks directory.',
79                default: 'resources/git-hooks',
80            )
81            ->addOption(
82                name: 'target',
83                shortcut: 't',
84                mode: InputOption::VALUE_OPTIONAL,
85                description: 'Path to the target Git hooks directory.',
86                default: '.git/hooks',
87            )
88            ->addOption(
89                name: 'no-overwrite',
90                mode: InputOption::VALUE_NONE,
91                description: 'Do not overwrite existing hook files.',
92            )
93            ->addOption(
94                name: 'dry-run',
95                mode: InputOption::VALUE_NONE,
96                description: 'Preview Git hook synchronization without copying files.',
97            )
98            ->addOption(
99                name: 'check',
100                mode: InputOption::VALUE_NONE,
101                description: 'Report Git hook drift and exit non-zero when replacements are required.',
102            )
103            ->addOption(
104                name: 'interactive',
105                mode: InputOption::VALUE_NONE,
106                description: 'Prompt before replacing drifted Git hooks.',
107            );
108    }
109
110    /**
111     * Copies packaged Git hooks.
112     *
113     * @param InputInterface $input the command input
114     * @param OutputInterface $output the command output
115     *
116     * @return int the command status code
117     */
118    protected function execute(InputInterface $input, OutputInterface $output): int
119    {
120        $sourcePath = $this->fileLocator->locate((string) $input->getOption('source'));
121        $targetPath = (string) $this->filesystem->getAbsolutePath((string) $input->getOption('target'));
122        $overwrite = ! $input->getOption('no-overwrite');
123        $dryRun = (bool) $input->getOption('dry-run');
124        $check = (bool) $input->getOption('check');
125        $interactive = (bool) $input->getOption('interactive');
126
127        $files = $this->finderFactory
128            ->create()
129            ->files()
130            ->in($sourcePath);
131
132        $status = self::SUCCESS;
133
134        foreach ($files as $file) {
135            $hookPath = Path::join($targetPath, $file->getRelativePathname());
136
137            if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) {
138                $this->notice(
139                    'Skipped existing {hook_name} hook.',
140                    $input,
141                    [
142                        'hook_name' => $file->getFilename(),
143                        'hook_path' => $hookPath,
144                    ],
145                );
146
147                continue;
148            }
149
150            if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($hookPath)) {
151                $comparison = $this->fileDiffer->diff($file->getRealPath(), $hookPath);
152
153                $this->logger->notice(
154                    $comparison->getSummary(),
155                    [
156                        'input' => $input,
157                        'hook_name' => $file->getFilename(),
158                        'hook_path' => $hookPath,
159                    ],
160                );
161
162                if ($comparison->isChanged()) {
163                    $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
164
165                    if (null !== $consoleDiff) {
166                        $this->logger->notice(
167                            $consoleDiff,
168                            [
169                                'input' => $input,
170                                'hook_name' => $file->getFilename(),
171                                'hook_path' => $hookPath,
172                                'diff' => $comparison->getDiff(),
173                            ],
174                        );
175                    }
176                }
177
178                if ($comparison->isUnchanged()) {
179                    continue;
180                }
181
182                if ($check) {
183                    $status = self::FAILURE;
184
185                    continue;
186                }
187
188                if ($dryRun) {
189                    continue;
190                }
191
192                if ($interactive && $input->isInteractive() && ! $this->shouldReplaceHook($hookPath)) {
193                    $this->notice(
194                        'Skipped replacing {hook_path}.',
195                        $input,
196                        [
197                            'hook_name' => $file->getFilename(),
198                            'hook_path' => $hookPath,
199                        ],
200                    );
201
202                    continue;
203                }
204            }
205
206            $this->filesystem->copy($file->getRealPath(), $hookPath, $overwrite || $interactive);
207            $this->filesystem->chmod($hookPath, 755, 0o755);
208
209            $this->success(
210                'Installed {hook_name} hook.',
211                $input,
212                [
213                    'hook_name' => $file->getFilename(),
214                    'hook_path' => $hookPath,
215                ],
216            );
217        }
218
219        if (self::FAILURE === $status) {
220            return $this->failure(
221                'One or more Git hooks require synchronization updates.',
222                $input,
223                [
224                    'target' => $targetPath,
225                ],
226                $targetPath,
227            );
228        }
229
230        return $this->success(
231            'Git hook synchronization completed successfully.',
232            $input,
233            [
234                'target' => $targetPath,
235            ],
236        );
237    }
238
239    /**
240     * Prompts whether a drifted hook should be replaced.
241     *
242     * @param string $hookPath the hook path that would be replaced
243     *
244     * @return bool true when the replacement SHOULD proceed
245     */
246    private function shouldReplaceHook(string $hookPath): bool
247    {
248        return $this->getIO()
249            ->askConfirmation(\sprintf('Replace drifted Git hook %s? [y/N] ', $hookPath), false);
250    }
251}