Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.74% covered (success)
98.74%
157 / 159
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitHooksCommand
98.74% covered (success)
98.74%
157 / 159
80.00% covered (warning)
80.00%
4 / 5
31
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%
36 / 36
100.00% covered (success)
100.00%
1 / 1
1
 execute
97.98% covered (success)
97.98%
97 / 99
0.00% covered (danger)
0.00%
0 / 1
24
 shouldReplaceHook
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 installHook
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
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 FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use FastForward\DevTools\Console\Input\HasJsonOption;
24use FastForward\DevTools\Filesystem\FinderFactoryInterface;
25use FastForward\DevTools\Filesystem\FilesystemInterface;
26use FastForward\DevTools\Resource\FileDiffer;
27use Psr\Log\LoggerInterface;
28use Symfony\Component\Config\FileLocatorInterface;
29use Symfony\Component\Console\Attribute\AsCommand;
30use Symfony\Component\Console\Command\Command;
31use Symfony\Component\Console\Input\InputInterface;
32use Symfony\Component\Console\Input\InputOption;
33use Symfony\Component\Console\Output\OutputInterface;
34use Symfony\Component\Console\Question\ConfirmationQuestion;
35use Symfony\Component\Console\Style\SymfonyStyle;
36use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
37use Symfony\Component\Filesystem\Path;
38
39/**
40 * Installs packaged Git hooks for the consumer repository.
41 */
42#[AsCommand(
43    name: 'git:hooks',
44    description: 'Installs Fast Forward Git hooks.',
45    aliases: ['.git/hooks', 'git-hooks'],
46)]
47 class GitHooksCommand extends Command
48{
49    use HasJsonOption;
50    use LogsCommandResults;
51
52    /**
53     * Creates a new GitHooksCommand instance.
54     *
55     * @param FilesystemInterface $filesystem the filesystem used to copy hooks
56     * @param FileLocatorInterface $fileLocator the locator used to find packaged hooks
57     * @param FinderFactoryInterface $finderFactory the factory used to create finders for hook files
58     * @param FileDiffer $fileDiffer
59     * @param LoggerInterface $logger the output-aware logger
60     * @param SymfonyStyle $io
61     */
62    public function __construct(
63        private  FilesystemInterface $filesystem,
64        private  FileLocatorInterface $fileLocator,
65        private  FinderFactoryInterface $finderFactory,
66        private  FileDiffer $fileDiffer,
67        private  LoggerInterface $logger,
68        private  SymfonyStyle $io,
69    ) {
70        parent::__construct();
71    }
72
73    /**
74     * Configures hook source, target, and initialization options.
75     */
76    protected function configure(): void
77    {
78        $this->setHelp('This command copies packaged Git hooks into the current repository.');
79
80        $this->addJsonOption()
81            ->addOption(
82                name: 'source',
83                shortcut: 's',
84                mode: InputOption::VALUE_OPTIONAL,
85                description: 'Path to the packaged Git hooks directory.',
86                default: 'resources/git-hooks',
87            )
88            ->addOption(
89                name: 'target',
90                shortcut: 't',
91                mode: InputOption::VALUE_OPTIONAL,
92                description: 'Path to the target Git hooks directory.',
93                default: '.git/hooks',
94            )
95            ->addOption(
96                name: 'no-overwrite',
97                mode: InputOption::VALUE_NONE,
98                description: 'Do not overwrite existing hook files.',
99            )
100            ->addOption(
101                name: 'dry-run',
102                mode: InputOption::VALUE_NONE,
103                description: 'Preview Git hook synchronization without copying files.',
104            )
105            ->addOption(
106                name: 'check',
107                mode: InputOption::VALUE_NONE,
108                description: 'Report Git hook drift and exit non-zero when replacements are required.',
109            )
110            ->addOption(
111                name: 'interactive',
112                mode: InputOption::VALUE_NONE,
113                description: 'Prompt before replacing drifted Git hooks.',
114            );
115    }
116
117    /**
118     * Copies packaged Git hooks.
119     *
120     * @param InputInterface $input the command input
121     * @param OutputInterface $output the command output
122     *
123     * @return int the command status code
124     */
125    protected function execute(InputInterface $input, OutputInterface $output): int
126    {
127        $sourcePath = $this->fileLocator->locate((string) $input->getOption('source'));
128        $targetPath = (string) $this->filesystem->getAbsolutePath((string) $input->getOption('target'));
129        $overwrite = ! $input->getOption('no-overwrite');
130        $dryRun = (bool) $input->getOption('dry-run');
131        $check = (bool) $input->getOption('check');
132        $interactive = (bool) $input->getOption('interactive');
133
134        $files = $this->finderFactory
135            ->create()
136            ->files()
137            ->in($sourcePath);
138
139        $checkFailure = false;
140        $installFailure = false;
141
142        foreach ($files as $file) {
143            $hookPath = Path::join($targetPath, $file->getRelativePathname());
144
145            if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) {
146                $this->notice(
147                    'Skipped existing {hook_name} hook.',
148                    $input,
149                    [
150                        'hook_name' => $file->getFilename(),
151                        'hook_path' => $hookPath,
152                    ],
153                );
154
155                continue;
156            }
157
158            if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($hookPath)) {
159                $comparison = $this->fileDiffer->diff($file->getRealPath(), $hookPath);
160
161                $this->logger->notice(
162                    $comparison->getSummary(),
163                    [
164                        'input' => $input,
165                        'hook_name' => $file->getFilename(),
166                        'hook_path' => $hookPath,
167                    ],
168                );
169
170                if ($comparison->isChanged()) {
171                    $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
172
173                    if (null !== $consoleDiff) {
174                        $this->logger->notice(
175                            $consoleDiff,
176                            [
177                                'input' => $input,
178                                'hook_name' => $file->getFilename(),
179                                'hook_path' => $hookPath,
180                                'diff' => $comparison->getDiff(),
181                            ],
182                        );
183                    }
184                }
185
186                if ($comparison->isUnchanged()) {
187                    continue;
188                }
189
190                if ($check) {
191                    $checkFailure = true;
192
193                    continue;
194                }
195
196                if ($dryRun) {
197                    continue;
198                }
199
200                if ($interactive && $input->isInteractive() && ! $this->shouldReplaceHook($hookPath)) {
201                    $this->notice(
202                        'Skipped replacing {hook_path}.',
203                        $input,
204                        [
205                            'hook_name' => $file->getFilename(),
206                            'hook_path' => $hookPath,
207                        ],
208                    );
209
210                    continue;
211                }
212            }
213
214            if (! $this->installHook($file->getRealPath(), $hookPath, $overwrite || $interactive, $input)) {
215                $installFailure = true;
216
217                continue;
218            }
219
220            $this->success(
221                'Installed {hook_name} hook.',
222                $input,
223                [
224                    'hook_name' => $file->getFilename(),
225                    'hook_path' => $hookPath,
226                ],
227            );
228        }
229
230        if ($checkFailure) {
231            return $this->failure(
232                'One or more Git hooks require synchronization updates.',
233                $input,
234                [
235                    'target' => $targetPath,
236                ],
237                $targetPath,
238            );
239        }
240
241        if ($installFailure) {
242            return $this->failure(
243                'One or more Git hooks could not be installed automatically.',
244                $input,
245                [
246                    'target' => $targetPath,
247                ],
248                $targetPath,
249            );
250        }
251
252        return $this->success(
253            'Git hook synchronization completed successfully.',
254            $input,
255            [
256                'target' => $targetPath,
257            ],
258        );
259    }
260
261    /**
262     * Prompts whether a drifted hook should be replaced.
263     *
264     * @param string $hookPath the hook path that would be replaced
265     *
266     * @return bool true when the replacement SHOULD proceed
267     */
268    private function shouldReplaceHook(string $hookPath): bool
269    {
270        $confirmation = new ConfirmationQuestion(
271            \sprintf('Replace drifted Git hook %s? [y/N] ', $hookPath),
272            false,
273        );
274
275        return $this->io->askQuestion($confirmation);
276    }
277
278    /**
279     * Installs a single hook and rewrites drifted targets defensively.
280     *
281     * @param string $sourcePath the packaged hook path
282     * @param string $hookPath the target repository hook path
283     * @param bool $replaceExisting whether an existing hook SHOULD be removed first
284     * @param InputInterface $input the originating command input
285     *
286     * @return bool true when the hook was installed successfully
287     */
288    private function installHook(
289        string $sourcePath,
290        string $hookPath,
291        bool $replaceExisting,
292        InputInterface $input
293    ): bool {
294        try {
295            if ($replaceExisting && $this->filesystem->exists($hookPath)) {
296                $this->filesystem->remove($hookPath);
297            }
298
299            $this->filesystem->copy($sourcePath, $hookPath, false);
300            $this->filesystem->chmod(files: $hookPath, mode: 0o755);
301
302            return true;
303        } catch (IOExceptionInterface $ioException) {
304            $this->logger->error(
305                'Failed to install {hook_name} hook automatically. Remove or unlock {hook_path} and rerun git-hooks.',
306                [
307                    'input' => $input,
308                    'hook_name' => $this->filesystem->getBasename($hookPath),
309                    'hook_path' => $hookPath,
310                    'error' => $ioException->getMessage(),
311                    'file' => $ioException->getPath() ?? $hookPath,
312                    'line' => null,
313                ],
314            );
315
316            return false;
317        }
318    }
319}