Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.19% covered (success)
99.19%
122 / 123
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitAttributesCommand
99.19% covered (success)
99.19%
122 / 123
80.00% covered (warning)
80.00%
4 / 5
15
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%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 execute
98.94% covered (success)
98.94%
93 / 94
0.00% covered (danger)
0.00%
0 / 1
11
 shouldWriteGitAttributes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 configuredKeepInExportPaths
100.00% covered (success)
100.00%
5 / 5
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\Composer\Json\ComposerJsonInterface;
23use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\Filesystem\FilesystemInterface;
26use FastForward\DevTools\GitAttributes\CandidateProviderInterface;
27use FastForward\DevTools\GitAttributes\ExistenceCheckerInterface;
28use FastForward\DevTools\GitAttributes\ExportIgnoreFilterInterface;
29use FastForward\DevTools\GitAttributes\MergerInterface;
30use FastForward\DevTools\GitAttributes\ReaderInterface;
31use FastForward\DevTools\GitAttributes\WriterInterface;
32use FastForward\DevTools\Resource\FileDiffer;
33use Psr\Log\LoggerInterface;
34use Symfony\Component\Console\Attribute\AsCommand;
35use Symfony\Component\Console\Command\Command;
36use Symfony\Component\Console\Input\InputInterface;
37use Symfony\Component\Console\Input\InputOption;
38use Symfony\Component\Console\Output\OutputInterface;
39use Symfony\Component\Console\Question\ConfirmationQuestion;
40use Symfony\Component\Console\Style\SymfonyStyle;
41
42use function Safe\getcwd;
43
44/**
45 * Provides functionality to manage .gitattributes export-ignore rules.
46 *
47 * This command adds export-ignore entries for repository-only files and directories
48 * to keep them out of Composer package archives.
49 */
50#[AsCommand(
51    name: 'git:attributes',
52    description: 'Manages .gitattributes export-ignore rules for leaner package archives.',
53    aliases: ['.gitattributes', 'gitattributes'],
54)]
55 class GitAttributesCommand extends Command
56{
57    use HasJsonOption;
58    use LogsCommandResults;
59
60    private const string FILENAME = '.gitattributes';
61
62    private const string EXTRA_NAMESPACE = 'gitattributes';
63
64    private const string EXTRA_KEEP_IN_EXPORT = 'keep-in-export';
65
66    private const string EXTRA_NO_EXPORT_IGNORE = 'no-export-ignore';
67
68    /**
69     * Creates a new GitAttributesCommand instance.
70     *
71     * @param CandidateProviderInterface $candidateProvider the candidate provider
72     * @param ExistenceCheckerInterface $existenceChecker the repository path existence checker
73     * @param ExportIgnoreFilterInterface $exportIgnoreFilter the configured candidate filter
74     * @param MergerInterface $merger the merger component
75     * @param ReaderInterface $reader the reader component
76     * @param WriterInterface $writer the writer component
77     * @param FilesystemInterface $filesystem the filesystem component
78     * @param ComposerJsonInterface $composer the composer.json accessor
79     * @param FileDiffer $fileDiffer
80     * @param LoggerInterface $logger the output-aware logger
81     * @param SymfonyStyle $io
82     */
83    public function __construct(
84        private  CandidateProviderInterface $candidateProvider,
85        private  ExistenceCheckerInterface $existenceChecker,
86        private  ExportIgnoreFilterInterface $exportIgnoreFilter,
87        private  MergerInterface $merger,
88        private  ReaderInterface $reader,
89        private  WriterInterface $writer,
90        private  ComposerJsonInterface $composer,
91        private  FilesystemInterface $filesystem,
92        private  FileDiffer $fileDiffer,
93        private  LoggerInterface $logger,
94        private  SymfonyStyle $io,
95    ) {
96        parent::__construct();
97    }
98
99    /**
100     * Configures verification and interactive update modes.
101     */
102    protected function configure(): void
103    {
104        $this->setHelp(
105            'This command adds export-ignore entries for repository-only files and directories to keep them out of Composer package archives. '
106            . 'Only paths that exist in the repository are added, existing custom rules are preserved, and '
107            . '"extra.gitattributes.keep-in-export" paths stay in exported archives.'
108        );
109
110        $this->addJsonOption()
111            ->addOption(
112                name: 'dry-run',
113                mode: InputOption::VALUE_NONE,
114                description: 'Preview .gitattributes synchronization without writing the file.',
115            )
116            ->addOption(
117                name: 'check',
118                mode: InputOption::VALUE_NONE,
119                description: 'Report .gitattributes 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 .gitattributes.',
125            );
126    }
127
128    /**
129     * Configures the current command.
130     *
131     * This method MUST define the name, description, and help text for the command.
132     *
133     * @param InputInterface $input
134     * @param OutputInterface $output
135     */
136    protected function execute(InputInterface $input, OutputInterface $output): int
137    {
138        $this->logger->info('Synchronizing .gitattributes export-ignore rules...', [
139            'input' => $input,
140        ]);
141        $dryRun = (bool) $input->getOption('dry-run');
142        $check = (bool) $input->getOption('check');
143        $interactive = (bool) $input->getOption('interactive');
144
145        $basePath = getcwd();
146        $keepInExportPaths = $this->configuredKeepInExportPaths();
147
148        $folderCandidates = $this->exportIgnoreFilter->filter($this->candidateProvider->folders(), $keepInExportPaths);
149        $fileCandidates = $this->exportIgnoreFilter->filter($this->candidateProvider->files(), $keepInExportPaths);
150
151        $existingFolders = $this->existenceChecker->filterExisting($basePath, $folderCandidates);
152        $existingFiles = $this->existenceChecker->filterExisting($basePath, $fileCandidates);
153
154        $entries = [...$existingFolders, ...$existingFiles];
155
156        if ([] === $entries) {
157            $this->notice('No candidate paths found in repository. Skipping .gitattributes sync.', $input);
158
159            return $this->success(
160                'No .gitattributes synchronization changes were required.',
161                $input,
162                logLevel: 'notice',
163            );
164        }
165
166        $gitattributesPath = $this->filesystem->getAbsolutePath(self::FILENAME);
167        $existingContent = $this->reader->read($gitattributesPath);
168        $content = $this->merger->merge($existingContent, $entries, $keepInExportPaths);
169        $renderedContent = $this->writer->render($content);
170        $comparison = $this->fileDiffer->diffContents(
171            'generated .gitattributes synchronization',
172            $gitattributesPath,
173            $renderedContent,
174            '' === $existingContent ? null : $this->writer->render($existingContent),
175            \sprintf('Updating managed file %s from generated .gitattributes synchronization.', $gitattributesPath),
176        );
177
178        $this->logger->notice(
179            $comparison->getSummary(),
180            [
181                'input' => $input,
182                'gitattributes_path' => $gitattributesPath,
183            ],
184        );
185
186        if ($comparison->isChanged()) {
187            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
188
189            if (null !== $consoleDiff) {
190                $this->logger->notice(
191                    $consoleDiff,
192                    [
193                        'input' => $input,
194                        'gitattributes_path' => $gitattributesPath,
195                        'diff' => $comparison->getDiff(),
196                    ],
197                );
198            }
199        }
200
201        if ($comparison->isUnchanged()) {
202            return $this->success('.gitattributes already matches the generated export-ignore rules.', $input);
203        }
204
205        if ($check) {
206            return $this->failure(
207                '.gitattributes requires synchronization updates.',
208                $input,
209                [
210                    'gitattributes_path' => $gitattributesPath,
211                ],
212                $gitattributesPath,
213            );
214        }
215
216        if ($dryRun) {
217            return $this->success(
218                '.gitattributes synchronization preview completed.',
219                $input,
220                [
221                    'gitattributes_path' => $gitattributesPath,
222                ],
223                'notice',
224            );
225        }
226
227        if ($interactive && $input->isInteractive() && ! $this->shouldWriteGitAttributes($gitattributesPath)) {
228            $this->notice(
229                'Skipped updating {gitattributes_path}.',
230                $input,
231                [
232                    'gitattributes_path' => $gitattributesPath,
233                ],
234            );
235
236            return $this->success(
237                '.gitattributes synchronization was skipped.',
238                $input,
239                [
240                    'gitattributes_path' => $gitattributesPath,
241                ],
242                'notice',
243            );
244        }
245
246        $this->writer->write($gitattributesPath, $content);
247
248        return $this->success(
249            'Added {entries_count} export-ignore entries to .gitattributes.',
250            $input,
251            [
252                'entries_count' => \count($entries),
253                'gitattributes_path' => $gitattributesPath,
254            ],
255        );
256    }
257
258    /**
259     * Prompts whether .gitattributes should be updated.
260     *
261     * @param string $targetPath the target path that would be updated
262     *
263     * @return bool true when the update SHOULD proceed
264     */
265    private function shouldWriteGitAttributes(string $targetPath): bool
266    {
267        $confirmation = new ConfirmationQuestion(\sprintf('Update managed file %s? [y/N] ', $targetPath), false);
268
269        return $this->io->askQuestion($confirmation);
270    }
271
272    /**
273     * Resolves the consumer-defined paths that MUST stay in exported archives.
274     *
275     * The preferred configuration key is "extra.gitattributes.keep-in-export".
276     * The alternate "extra.gitattributes.no-export-ignore" key remains
277     * supported as a compatibility alias.
278     *
279     * @return list<string> the configured keep-in-export paths
280     */
281    private function configuredKeepInExportPaths(): array
282    {
283        $extra = $this->composer->getExtra(self::EXTRA_NAMESPACE);
284
285        return array_unique(array_merge(
286            $extra[self::EXTRA_KEEP_IN_EXPORT] ?? [],
287            $extra[self::EXTRA_NO_EXPORT_IGNORE] ?? [],
288        ));
289    }
290}