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