Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.24% covered (success)
90.24%
185 / 205
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitHooksCommand
90.24% covered (success)
90.24%
185 / 205
66.67% covered (warning)
66.67%
4 / 6
41.49
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.64% covered (success)
97.64%
124 / 127
0.00% covered (danger)
0.00%
0 / 1
30
 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        $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);
150            $hookPath = Path::join($targetPath, $file->getRelativePathname());
151
152            if (($dryRun || $check) && ! $this->filesystem->exists($hookPath)) {
153                $this->log(
154                    'Git hook {hook_path} is missing and would be installed.',
155                    $input,
156                    [
157                        'hook_name' => $file->getFilename(),
158                        'hook_path' => $hookPath,
159                    ],
160                    LogLevel::NOTICE,
161                );
162                $checkFailure = $checkFailure || $check;
163
164                continue;
165            }
166
167            if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) {
168                $this->log(
169                    'Skipped existing {hook_name} hook.',
170                    $input,
171                    [
172                        'hook_name' => $file->getFilename(),
173                        'hook_path' => $hookPath,
174                    ],
175                    LogLevel::NOTICE,
176                );
177
178                continue;
179            }
180
181            if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($hookPath)) {
182                $comparison = $sourceContents === $renderedSourceContents
183                    ? $this->fileDiffer->diff($sourcePath, $hookPath)
184                    : $this->compareRenderedHookContents($sourcePath, $hookPath, $renderedSourceContents);
185
186                $this->log(
187                    $comparison->getSummary(),
188                    $input,
189                    [
190                        'hook_name' => $file->getFilename(),
191                        'hook_path' => $hookPath,
192                    ],
193                    LogLevel::NOTICE,
194                );
195
196                if ($comparison->isChanged()) {
197                    $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
198
199                    if (null !== $consoleDiff) {
200                        $this->log(
201                            $consoleDiff,
202                            $input,
203                            [
204                                'hook_name' => $file->getFilename(),
205                                'hook_path' => $hookPath,
206                                'diff' => $comparison->getDiff(),
207                            ],
208                            LogLevel::NOTICE,
209                        );
210                    }
211                }
212
213                if ($comparison->isUnchanged()) {
214                    continue;
215                }
216
217                if ($check) {
218                    $checkFailure = true;
219
220                    continue;
221                }
222
223                if ($dryRun) {
224                    continue;
225                }
226
227                if ($interactive && $input->isInteractive() && ! $this->shouldReplaceHook($hookPath)) {
228                    $this->log(
229                        'Skipped replacing {hook_path}.',
230                        $input,
231                        [
232                            'hook_name' => $file->getFilename(),
233                            'hook_path' => $hookPath,
234                        ],
235                        LogLevel::NOTICE,
236                    );
237
238                    continue;
239                }
240            }
241
242            if (! $this->installHook(
243                $sourcePath,
244                $hookPath,
245                $overwrite || $interactive,
246                $input,
247                $sourceContents === $renderedSourceContents ? null : $renderedSourceContents,
248            )) {
249                $installFailure = true;
250
251                continue;
252            }
253
254            $this->success(
255                'Installed {hook_name} hook.',
256                $input,
257                [
258                    'hook_name' => $file->getFilename(),
259                    'hook_path' => $hookPath,
260                ],
261            );
262        }
263
264        if ($checkFailure) {
265            return $this->failure(
266                'One or more Git hooks require synchronization updates.',
267                $input,
268                [
269                    'target' => $targetPath,
270                ],
271                $targetPath,
272            );
273        }
274
275        if ($installFailure) {
276            return $this->failure(
277                'One or more Git hooks could not be installed automatically.',
278                $input,
279                [
280                    'target' => $targetPath,
281                ],
282                $targetPath,
283            );
284        }
285
286        return $this->success(
287            'Git hook synchronization completed successfully.',
288            $input,
289            [
290                'target' => $targetPath,
291            ],
292        );
293    }
294
295    /**
296     * Prompts whether a drifted hook should be replaced.
297     *
298     * @param string $hookPath the hook path that would be replaced
299     *
300     * @return bool true when the replacement SHOULD proceed
301     */
302    private function shouldReplaceHook(string $hookPath): bool
303    {
304        $confirmation = new ConfirmationQuestion(
305            \sprintf('Replace drifted Git hook %s? [y/N] ', $hookPath),
306            false,
307        );
308
309        return $this->io->askQuestion($confirmation);
310    }
311
312    /**
313     * Installs a single hook and rewrites drifted targets defensively.
314     *
315     * @param string $sourcePath the packaged hook path
316     * @param string $hookPath the target repository hook path
317     * @param bool $replaceExisting whether an existing hook SHOULD be removed first
318     * @param InputInterface $input the originating command input
319     * @param string|null $renderedContents optional rendered hook contents that SHOULD be written instead of copied
320     *
321     * @return bool true when the hook was installed successfully
322     */
323    private function installHook(
324        string $sourcePath,
325        string $hookPath,
326        bool $replaceExisting,
327        InputInterface $input,
328        ?string $renderedContents = null,
329    ): bool {
330        try {
331            if ($replaceExisting && $this->filesystem->exists($hookPath)) {
332                $this->filesystem->remove($hookPath);
333            }
334
335            if (null === $renderedContents) {
336                $this->filesystem->copy($sourcePath, $hookPath, false);
337            } else {
338                $this->filesystem->dumpFile($hookPath, $renderedContents);
339            }
340
341            $this->filesystem->chmod(files: $hookPath, mode: 0o755);
342
343            return true;
344        } catch (IOExceptionInterface $ioException) {
345            $this->failure(
346                'Failed to install {hook_name} hook automatically. Remove or unlock {hook_path} and rerun git-hooks.',
347                $input,
348                [
349                    'hook_name' => $this->filesystem->getBasename($hookPath),
350                    'hook_path' => $hookPath,
351                    'error' => $ioException->getMessage(),
352                ],
353                $ioException->getPath() ?? $hookPath,
354            );
355
356            return false;
357        }
358    }
359
360    /**
361     * Compares rendered hook contents with an existing installed hook.
362     *
363     * @param string $sourcePath the packaged hook source path
364     * @param string $hookPath the target installed hook path
365     * @param string $renderedContents the rendered hook contents
366     *
367     * @return FileDiff the rendered comparison result
368     */
369    private function compareRenderedHookContents(
370        string $sourcePath,
371        string $hookPath,
372        string $renderedContents
373    ): FileDiff {
374        try {
375            $targetContents = $this->filesystem->readFile($hookPath);
376        } catch (Throwable) {
377            return new FileDiff(
378                FileDiff::STATUS_UNREADABLE,
379                \sprintf(
380                    'Target %s will be overwritten from %s, but the existing or source content could not be read.',
381                    $hookPath,
382                    $sourcePath,
383                ),
384            );
385        }
386
387        return $this->fileDiffer->diffContents(
388            $sourcePath,
389            $hookPath,
390            $renderedContents,
391            $targetContents,
392            \sprintf('Overwriting resource %s from %s.', $hookPath, $sourcePath),
393        );
394    }
395}