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