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