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