Skip to content

Commit 439ec4d

Browse files
committed
feat: Introduce GitAttributes management with Reader, Merger, and Writer implementations
- Added Reader and ReaderInterface for reading .gitattributes files. - Implemented Merger and MergerInterface to handle merging export-ignore entries. - Created Writer and WriterInterface for writing normalized .gitattributes content. - Added ExportIgnoreFilter to manage paths to be ignored during export. - Developed tests for Reader, Merger, Writer, and ExportIgnoreFilter functionalities. - Updated GitAttributesCommand to utilize new Reader, Merger, and Writer classes. - Enhanced existing tests to cover new functionality and ensure proper behavior. Signed-off-by: Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
1 parent b9598a2 commit 439ec4d

22 files changed

Lines changed: 1448 additions & 162 deletions

.gitattributes

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,9 @@
1-
* text=auto
2-
/.agents/ export-ignore
3-
/.github/ export-ignore
4-
/docs/ export-ignore
5-
/tests/ export-ignore
6-
/.gitattributes export-ignore
7-
/.gitignore export-ignore
8-
/.gitmodules export-ignore
9-
AGENTS.md export-ignore
10-
# << dev-tools:managed export-ignore
11-
/.github/ export-ignore
12-
/.vscode/ export-ignore
13-
/docs/ export-ignore
14-
/tests/ export-ignore
15-
/.editorconfig export-ignore
1+
* text=auto
2+
/.github/ export-ignore
3+
/.vscode/ export-ignore
4+
/docs/ export-ignore
5+
/tests/ export-ignore
166
/.gitattributes export-ignore
17-
/.gitignore export-ignore
18-
/.gitmodules export-ignore
19-
/README.md export-ignore
20-
# >> dev-tools:managed export-ignore
7+
/.gitmodules export-ignore
8+
/AGENTS.md export-ignore
9+
/README.md export-ignore

composer.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@
8585
"dev-main": "1.x-dev"
8686
},
8787
"class": "FastForward\\DevTools\\Composer\\Plugin",
88+
"gitattributes": {
89+
"keep-in-export": [
90+
"/.agents/",
91+
"/.editorconfig",
92+
"/.gitignore",
93+
"/.php-cs-fixer.dist.php",
94+
"/ecs.php",
95+
"/grumphp.yml",
96+
"/phpunit.xml",
97+
"/rector.php"
98+
]
99+
},
88100
"grumphp": {
89101
"config-default-path": "grumphp.yml"
90102
}

src/Command/GitAttributesCommand.php

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@
2121
use FastForward\DevTools\GitAttributes\CandidateProvider;
2222
use FastForward\DevTools\GitAttributes\CandidateProviderInterface;
2323
use FastForward\DevTools\GitAttributes\ExistenceChecker;
24+
use FastForward\DevTools\GitAttributes\ExistenceCheckerInterface;
25+
use FastForward\DevTools\GitAttributes\ExportIgnoreFilter;
26+
use FastForward\DevTools\GitAttributes\ExportIgnoreFilterInterface;
2427
use 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;
2533
use Symfony\Component\Console\Input\InputInterface;
2634
use Symfony\Component\Console\Output\OutputInterface;
2735
use Symfony\Component\Filesystem\Filesystem;
@@ -35,17 +43,56 @@
3543
*/
3644
final 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
}

src/GitAttributes/CandidateProvider.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ final class CandidateProvider implements CandidateProviderInterface
3333
public function folders(): array
3434
{
3535
return [
36+
'/.devcontainer/',
3637
'/.github/',
3738
'/.idea/',
3839
'/.vscode/',
@@ -63,6 +64,16 @@ public function files(): array
6364
'/Makefile',
6465
'/phpunit.xml.dist',
6566
'/README.md',
67+
'/AGENTS.md',
68+
'/GEMINI.md',
69+
'/Dockerfile',
70+
'/.dockerignore',
71+
'/.env',
72+
'/docker-compose.yml',
73+
'/docker-compose.override.yml',
74+
'/docker-stack.yml',
75+
'/compose.yml',
76+
'/compose.override.yml',
6677
];
6778
}
6879

src/GitAttributes/ExistenceChecker.php

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,70 +28,75 @@
2828
*/
2929
final readonly class ExistenceChecker implements ExistenceCheckerInterface
3030
{
31-
private string $basePath;
32-
3331
/**
34-
* @param string $basePath The base directory to check paths against
3532
* @param Filesystem $filesystem
3633
*/
3734
public function __construct(
38-
string $basePath,
3935
private Filesystem $filesystem = new Filesystem()
40-
) {
41-
$this->basePath = rtrim($basePath, '/');
42-
}
36+
) {}
4337

4438
/**
4539
* Checks if a path exists as a file or directory.
4640
*
41+
* @param string $basePath the repository base path used to resolve the candidate
4742
* @param string $path The path to check (e.g., "/.github/" or "/.editorconfig")
4843
*
4944
* @return bool True if the path exists as a file or directory
5045
*/
51-
public function exists(string $path): bool
46+
public function exists(string $basePath, string $path): bool
5247
{
53-
$fullPath = $this->basePath . $path;
54-
55-
return $this->filesystem->exists($fullPath);
48+
return $this->filesystem->exists($this->absolutePath($basePath, $path));
5649
}
5750

5851
/**
5952
* Filters a list of paths to only those that exist.
6053
*
54+
* @param string $basePath the repository base path used to resolve the candidates
6155
* @param list<string> $paths The paths to filter
6256
*
6357
* @return list<string> Only the paths that exist
6458
*/
65-
public function filterExisting(array $paths): array
59+
public function filterExisting(string $basePath, array $paths): array
6660
{
67-
return array_values(array_filter($paths, $this->exists(...)));
61+
return array_values(array_filter($paths, fn(string $path): bool => $this->exists($basePath, $path)));
6862
}
6963

7064
/**
7165
* Checks if a path is a directory.
7266
*
67+
* @param string $basePath the repository base path used to resolve the candidate
7368
* @param string $path The path to check (e.g., "/.github/")
7469
*
7570
* @return bool True if the path exists and is a directory
7671
*/
77-
public function isDirectory(string $path): bool
72+
public function isDirectory(string $basePath, string $path): bool
7873
{
79-
$fullPath = $this->basePath . $path;
80-
81-
return is_dir($fullPath);
74+
return is_dir($this->absolutePath($basePath, $path));
8275
}
8376

8477
/**
8578
* Checks if a path is a file.
8679
*
80+
* @param string $basePath the repository base path used to resolve the candidate
8781
* @param string $path The path to check (e.g., "/.editorconfig")
8882
*
8983
* @return bool True if the path exists and is a file
9084
*/
91-
public function isFile(string $path): bool
85+
public function isFile(string $basePath, string $path): bool
9286
{
93-
$fullPath = $this->basePath . $path;
87+
return is_file($this->absolutePath($basePath, $path));
88+
}
9489

95-
return is_file($fullPath);
90+
/**
91+
* Resolves a candidate path against the repository base path.
92+
*
93+
* @param string $basePath the repository base path
94+
* @param string $path the candidate path in canonical form
95+
*
96+
* @return string the absolute path used for filesystem checks
97+
*/
98+
private function absolutePath(string $basePath, string $path): string
99+
{
100+
return rtrim($basePath, '/\\') . $path;
96101
}
97102
}

src/GitAttributes/ExistenceCheckerInterface.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,36 +29,40 @@ interface ExistenceCheckerInterface
2929
/**
3030
* Checks if a path exists as a file or directory.
3131
*
32+
* @param string $basePath the repository base path used to resolve the candidate
3233
* @param string $path The path to check (e.g., "/.github/" or "/.editorconfig")
3334
*
3435
* @return bool True if the path exists as a file or directory
3536
*/
36-
public function exists(string $path): bool;
37+
public function exists(string $basePath, string $path): bool;
3738

3839
/**
3940
* Filters a list of paths to only those that exist.
4041
*
42+
* @param string $basePath the repository base path used to resolve the candidates
4143
* @param list<string> $paths The paths to filter
4244
*
4345
* @return list<string> Only the paths that exist
4446
*/
45-
public function filterExisting(array $paths): array;
47+
public function filterExisting(string $basePath, array $paths): array;
4648

4749
/**
4850
* Checks if a path is a directory.
4951
*
52+
* @param string $basePath the repository base path used to resolve the candidate
5053
* @param string $path The path to check (e.g., "/.github/")
5154
*
5255
* @return bool True if the path exists and is a directory
5356
*/
54-
public function isDirectory(string $path): bool;
57+
public function isDirectory(string $basePath, string $path): bool;
5558

5659
/**
5760
* Checks if a path is a file.
5861
*
62+
* @param string $basePath the repository base path used to resolve the candidate
5963
* @param string $path The path to check (e.g., "/.editorconfig")
6064
*
6165
* @return bool True if the path exists and is a file
6266
*/
63-
public function isFile(string $path): bool;
67+
public function isFile(string $basePath, string $path): bool;
6468
}

0 commit comments

Comments
 (0)