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