Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.21% covered (success)
91.21%
83 / 91
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitIgnoreCommand
91.21% covered (success)
91.21%
83 / 91
75.00% covered (warning)
75.00%
3 / 4
12.10
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%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 execute
85.45% covered (warning)
85.45%
47 / 55
0.00% covered (danger)
0.00%
0 / 1
9.25
 shouldWriteGitIgnore
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 FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use FastForward\DevTools\Console\Input\HasJsonOption;
24use FastForward\DevTools\GitIgnore\MergerInterface;
25use FastForward\DevTools\GitIgnore\ReaderInterface;
26use FastForward\DevTools\GitIgnore\WriterInterface;
27use FastForward\DevTools\Path\DevToolsPathResolver;
28use FastForward\DevTools\Resource\FileDiffer;
29use Psr\Log\LoggerInterface;
30use Psr\Log\LogLevel;
31use Symfony\Component\Config\FileLocatorInterface;
32use Symfony\Component\Console\Attribute\AsCommand;
33use Symfony\Component\Console\Command\Command;
34use Symfony\Component\Console\Input\InputInterface;
35use Symfony\Component\Console\Input\InputOption;
36use Symfony\Component\Console\Output\OutputInterface;
37use Symfony\Component\Console\Question\ConfirmationQuestion;
38use Symfony\Component\Console\Style\SymfonyStyle;
39
40/**
41 * Provides functionality to merge and synchronize .gitignore files.
42 *
43 * This command merges the canonical .gitignore from dev-tools with the project's
44 * existing .gitignore, removing duplicates and sorting entries.
45 *
46 * The command accepts two options: --source and --target to specify the paths
47 * to the canonical and project .gitignore files respectively.
48 */
49#[AsCommand(
50    name: 'git:ignore',
51    description: 'Merges and synchronizes .gitignore files.',
52    aliases: ['.gitignore', 'gitignore'],
53)]
54 class GitIgnoreCommand extends Command
55{
56    use HasJsonOption;
57    use LogsCommandResults;
58
59    /**
60     * @var string the default filename for .gitignore files
61     */
62    public const string FILENAME = '.gitignore';
63
64    /**
65     * Creates a new GitIgnoreCommand instance.
66     *
67     * @param MergerInterface $merger the merger component
68     * @param ReaderInterface $reader the reader component
69     * @param WriterInterface|null $writer the writer component
70     * @param FileLocatorInterface $fileLocator the file locator
71     * @param FileDiffer $fileDiffer
72     * @param LoggerInterface $logger the output-aware logger
73     * @param SymfonyStyle $io
74     */
75    public function __construct(
76        private  MergerInterface $merger,
77        private  ReaderInterface $reader,
78        private  WriterInterface $writer,
79        private  FileLocatorInterface $fileLocator,
80        private  FileDiffer $fileDiffer,
81        private  LoggerInterface $logger,
82        private  SymfonyStyle $io,
83    ) {
84        parent::__construct();
85    }
86
87    /**
88     * Configures the current command.
89     *
90     * This method MUST define the name, description, and help text for the command.
91     * It SHALL identify the tool as the mechanism for script synchronization.
92     */
93    protected function configure(): void
94    {
95        $this->setHelp(
96            "This command merges the canonical .gitignore from dev-tools with the project's existing .gitignore."
97        );
98
99        $this->addJsonOption()
100            ->addOption(
101                name: 'source',
102                shortcut: 's',
103                mode: InputOption::VALUE_OPTIONAL,
104                description: 'Path to the source .gitignore file (canonical)',
105                default: DevToolsPathResolver::getPackagePath(self::FILENAME),
106            )
107            ->addOption(
108                name: 'target',
109                shortcut: 't',
110                mode: InputOption::VALUE_OPTIONAL,
111                description: 'Path to the target .gitignore file (project)',
112                default: $this->fileLocator->locate(self::FILENAME)
113            )
114            ->addOption(
115                name: 'dry-run',
116                mode: InputOption::VALUE_NONE,
117                description: 'Preview .gitignore synchronization without writing the file.',
118            )
119            ->addOption(
120                name: 'check',
121                mode: InputOption::VALUE_NONE,
122                description: 'Report .gitignore drift and exit non-zero when changes are required.',
123            )
124            ->addOption(
125                name: 'interactive',
126                mode: InputOption::VALUE_NONE,
127                description: 'Prompt before updating .gitignore.',
128            );
129    }
130
131    /**
132     * Executes the gitignore merge process.
133     *
134     * @param InputInterface $input the input interface
135     * @param OutputInterface $output the output interface
136     *
137     * @return int the status code
138     */
139    protected function execute(InputInterface $input, OutputInterface $output): int
140    {
141        $this->logger->info('Merging .gitignore files...', [
142            'input' => $input,
143        ]);
144
145        $sourcePath = $input->getOption('source');
146        $targetPath = $input->getOption('target');
147        $dryRun = (bool) $input->getOption('dry-run');
148        $check = (bool) $input->getOption('check');
149        $interactive = (bool) $input->getOption('interactive');
150
151        $canonical = $this->reader->read($sourcePath);
152        $project = $this->reader->read($targetPath);
153
154        $merged = $this->merger->merge($canonical, $project);
155        $comparison = $this->fileDiffer->diffContents(
156            'generated .gitignore synchronization',
157            $merged->path(),
158            $this->writer->render($merged),
159            $this->writer->render($project),
160            \sprintf('Updating managed file %s from generated .gitignore synchronization.', $merged->path()),
161        );
162
163        $this->notice($comparison->getSummary(), $input, [
164            'target_path' => $merged->path(),
165        ]);
166
167        if ($comparison->isChanged()) {
168            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
169
170            if (null !== $consoleDiff) {
171                $this->notice(
172                    $consoleDiff,
173                    $input,
174                    [
175                        'target_path' => $merged->path(),
176                        'diff' => $comparison->getDiff(),
177                    ],
178                );
179            }
180        }
181
182        if ($comparison->isUnchanged()) {
183            return self::SUCCESS;
184        }
185
186        if ($check) {
187            return self::FAILURE;
188        }
189
190        if ($dryRun) {
191            return self::SUCCESS;
192        }
193
194        if ($interactive && $input->isInteractive() && ! $this->shouldWriteGitIgnore($merged->path())) {
195            return $this->success(
196                'Skipped updating {target_path}.',
197                $input,
198                [
199                    'target_path' => $merged->path(),
200                ],
201                LogLevel::NOTICE,
202            );
203        }
204
205        $this->writer->write($merged);
206
207        return $this->success(
208            'Successfully merged .gitignore file.',
209            $input,
210            [
211                'target_path' => $merged->path(),
212            ],
213        );
214    }
215
216    /**
217     * Prompts whether .gitignore should be updated.
218     *
219     * @param string $targetPath the target path that would be updated
220     *
221     * @return bool true when the update SHOULD proceed
222     */
223    private function shouldWriteGitIgnore(string $targetPath): bool
224    {
225        $confirmation = new ConfirmationQuestion(\sprintf('Update managed file %s? [y/N] ', $targetPath), false);
226
227        return $this->io->askQuestion($confirmation);
228    }
229}