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