2121use FastForward \DevTools \GitAttributes \CandidateProvider ;
2222use FastForward \DevTools \GitAttributes \CandidateProviderInterface ;
2323use FastForward \DevTools \GitAttributes \ExistenceChecker ;
24+ use FastForward \DevTools \GitAttributes \ExistenceCheckerInterface ;
25+ use FastForward \DevTools \GitAttributes \ExportIgnoreFilter ;
26+ use FastForward \DevTools \GitAttributes \ExportIgnoreFilterInterface ;
2427use FastForward \DevTools \GitAttributes \Merger ;
28+ use FastForward \DevTools \GitAttributes \MergerInterface ;
29+ use FastForward \DevTools \GitAttributes \Reader ;
30+ use FastForward \DevTools \GitAttributes \ReaderInterface ;
31+ use FastForward \DevTools \GitAttributes \Writer ;
32+ use FastForward \DevTools \GitAttributes \WriterInterface ;
2533use Symfony \Component \Console \Input \InputInterface ;
2634use Symfony \Component \Console \Output \OutputInterface ;
2735use Symfony \Component \Filesystem \Filesystem ;
3543 */
3644final class GitAttributesCommand extends AbstractCommand
3745{
46+ private const string EXTRA_NAMESPACE = 'gitattributes ' ;
47+
48+ private const string EXTRA_KEEP_IN_EXPORT = 'keep-in-export ' ;
49+
50+ private const string EXTRA_NO_EXPORT_IGNORE = 'no-export-ignore ' ;
51+
52+ private readonly WriterInterface $ writer ;
53+
3854 /**
3955 * Creates a new GitAttributesCommand instance.
4056 *
4157 * @param Filesystem|null $filesystem the filesystem component
42- * @param CandidateProviderInterface|null $candidateProvider the candidate provider
58+ * @param CandidateProviderInterface $candidateProvider the candidate provider
59+ * @param ExistenceCheckerInterface $existenceChecker the repository path existence checker
60+ * @param ExportIgnoreFilterInterface $exportIgnoreFilter the configured candidate filter
61+ * @param MergerInterface $merger the merger component
62+ * @param ReaderInterface $reader the reader component
63+ * @param WriterInterface|null $writer the writer component
4364 */
4465 public function __construct (
4566 ?Filesystem $ filesystem = null ,
46- private readonly ?CandidateProviderInterface $ candidateProvider = new CandidateProvider ()
67+ private readonly CandidateProviderInterface $ candidateProvider = new CandidateProvider (),
68+ private readonly ExistenceCheckerInterface $ existenceChecker = new ExistenceChecker (),
69+ private readonly ExportIgnoreFilterInterface $ exportIgnoreFilter = new ExportIgnoreFilter (),
70+ private readonly MergerInterface $ merger = new Merger (),
71+ private readonly ReaderInterface $ reader = new Reader (),
72+ ?WriterInterface $ writer = null ,
4773 ) {
4874 parent ::__construct ($ filesystem );
75+ $ this ->writer = $ writer ?? new Writer ($ this ->filesystem );
76+ }
77+
78+ /**
79+ * Configures the current command.
80+ *
81+ * This method MUST define the name, description, and help text for the command.
82+ *
83+ * @return void
84+ */
85+ protected function configure (): void
86+ {
87+ $ this
88+ ->setName ('gitattributes ' )
89+ ->setDescription ('Manages .gitattributes export-ignore rules for leaner package archives. ' )
90+ ->setHelp (
91+ 'This command adds export-ignore entries for repository-only files and directories '
92+ . 'to keep them out of Composer package archives. Only paths that exist in the '
93+ . 'repository are added, existing custom rules are preserved, and '
94+ . '"extra.gitattributes.keep-in-export" paths stay in exported archives. '
95+ );
4996 }
5097
5198 /**
@@ -61,15 +108,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
61108 $ output ->writeln ('<info>Synchronizing .gitattributes export-ignore rules...</info> ' );
62109
63110 $ basePath = $ this ->getCurrentWorkingDirectory ();
111+ $ keepInExportPaths = $ this ->configuredKeepInExportPaths ();
64112
65- /** @var ExistenceChecker $checker */
66- $ checker = new ExistenceChecker ($ basePath , $ this ->filesystem );
67-
68- $ existingFolders = $ checker ->filterExisting ($ this ->candidateProvider ->folders ());
69- $ existingFiles = $ checker ->filterExisting ($ this ->candidateProvider ->files ());
113+ $ folderCandidates = $ this ->exportIgnoreFilter ->filter ($ this ->candidateProvider ->folders (), $ keepInExportPaths );
114+ $ fileCandidates = $ this ->exportIgnoreFilter ->filter ($ this ->candidateProvider ->files (), $ keepInExportPaths );
70115
71- sort ( $ existingFolders, \ SORT_STRING );
72- sort ( $ existingFiles, \ SORT_STRING );
116+ $ existingFolders = $ this -> existenceChecker -> filterExisting ( $ basePath , $ folderCandidates );
117+ $ existingFiles = $ this -> existenceChecker -> filterExisting ( $ basePath , $ fileCandidates );
73118
74119 $ entries = [...$ existingFolders , ...$ existingFiles ];
75120
@@ -82,10 +127,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
82127 }
83128
84129 $ gitattributesPath = Path::join ($ basePath , '.gitattributes ' );
85- $ merger = new Merger ($ gitattributesPath );
86-
87- $ content = $ merger ->merge ($ entries );
88- $ merger ->write ($ content );
130+ $ existingContent = $ this ->reader ->read ($ gitattributesPath );
131+ $ content = $ this ->merger ->merge ($ existingContent , $ entries , $ keepInExportPaths );
132+ $ this ->writer ->write ($ gitattributesPath , $ content );
89133
90134 $ output ->writeln (\sprintf (
91135 '<info>Added %d export-ignore entries to .gitattributes.</info> ' ,
@@ -96,21 +140,46 @@ protected function execute(InputInterface $input, OutputInterface $output): int
96140 }
97141
98142 /**
99- * Configures the current command .
143+ * Resolves the consumer-defined paths that MUST stay in exported archives .
100144 *
101- * This method MUST define the name, description, and help text for the command.
145+ * The preferred configuration key is "extra.gitattributes.keep-in-export".
146+ * The alternate "extra.gitattributes.no-export-ignore" key remains
147+ * supported as a compatibility alias.
102148 *
103- * @return void
149+ * @return list<string> the configured keep-in-export paths
104150 */
105- protected function configure (): void
151+ private function configuredKeepInExportPaths (): array
106152 {
107- $ this
108- ->setName ('gitattributes ' )
109- ->setDescription ('Manages .gitattributes export-ignore rules for leaner package archives. ' )
110- ->setHelp (
111- 'This command adds export-ignore entries for repository-only files and directories '
112- . 'to keep them out of Composer package archives. Only paths that exist in the '
113- . 'repository are added, and existing custom rules are preserved. '
114- );
153+ $ extra = $ this ->requireComposer ()
154+ ->getPackage ()
155+ ->getExtra ();
156+
157+ $ gitattributesConfig = $ extra [self ::EXTRA_NAMESPACE ] ?? null ;
158+
159+ if (! \is_array ($ gitattributesConfig )) {
160+ return [];
161+ }
162+
163+ $ configuredPaths = [];
164+
165+ foreach ([self ::EXTRA_KEEP_IN_EXPORT , self ::EXTRA_NO_EXPORT_IGNORE ] as $ key ) {
166+ $ values = $ gitattributesConfig [$ key ] ?? [];
167+
168+ if (\is_string ($ values )) {
169+ $ values = [$ values ];
170+ }
171+
172+ if (! \is_array ($ values )) {
173+ continue ;
174+ }
175+
176+ foreach ($ values as $ value ) {
177+ if (\is_string ($ value )) {
178+ $ configuredPaths [] = $ value ;
179+ }
180+ }
181+ }
182+
183+ return array_values (array_unique ($ configuredPaths ));
115184 }
116185}
0 commit comments