From 500f85beb5561d16df6d922474edf7838d2e5769 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 04:02:03 +0000 Subject: [PATCH 1/6] refactor: isolate GitAttributesCommand as standalone command - Extract GitAttributesCommand from SyncCommand for standalone execution - SyncCommand now calls gitattributes via runCommand like gitignore - Register GitAttributesCommand in DevToolsCommandProvider - Update tests to include new command and dependencies --- .gitattributes | 11 ++ .github/wiki | 2 +- src/Command/SyncCommand.php | 5 +- .../Capability/DevToolsCommandProvider.php | 2 + src/GitAttributes/CandidateProvider.php | 78 +++++++++ .../CandidateProviderInterface.php | 50 ++++++ src/GitAttributes/ExistenceChecker.php | 97 +++++++++++ .../ExistenceCheckerInterface.php | 64 +++++++ src/GitAttributes/Merger.php | 159 ++++++++++++++++++ src/GitAttributes/MergerInterface.php | 54 ++++++ tests/Command/SyncCommandTest.php | 14 +- .../DevToolsCommandProviderTest.php | 13 +- 12 files changed, 540 insertions(+), 9 deletions(-) create mode 100644 src/GitAttributes/CandidateProvider.php create mode 100644 src/GitAttributes/CandidateProviderInterface.php create mode 100644 src/GitAttributes/ExistenceChecker.php create mode 100644 src/GitAttributes/ExistenceCheckerInterface.php create mode 100644 src/GitAttributes/Merger.php create mode 100644 src/GitAttributes/MergerInterface.php diff --git a/.gitattributes b/.gitattributes index d5b4ded..433ec33 100755 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,14 @@ /.gitignore export-ignore /.gitmodules export-ignore AGENTS.md export-ignore +# << dev-tools:managed export-ignore +/.github/ export-ignore +/.vscode/ export-ignore +/docs/ export-ignore +/tests/ export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.gitmodules export-ignore +/README.md export-ignore +# >> dev-tools:managed export-ignore diff --git a/.github/wiki b/.github/wiki index 752f977..6ba2180 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 752f9775580d895735b26e11f22835a00f63f315 +Subproject commit 6ba21809ba8a75d60f76db14b5a9d1519b2561f2 diff --git a/src/Command/SyncCommand.php b/src/Command/SyncCommand.php index 2384a43..d2f39cb 100644 --- a/src/Command/SyncCommand.php +++ b/src/Command/SyncCommand.php @@ -47,10 +47,10 @@ protected function configure(): void $this ->setName('dev-tools:sync') ->setDescription( - 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, and .editorconfig in the root project.' + 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and .gitattributes in the root project.' ) ->setHelp( - 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, and ensures .editorconfig is present and up to date.' + 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.' ); } @@ -75,6 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->copyDependabotConfig(); $this->addRepositoryWikiGitSubmodule(); $this->runCommand('gitignore', $output); + $this->runCommand('gitattributes', $output); $this->runCommand('skills', $output); $this->runCommand('license', $output); diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php index 515f3e6..f356b50 100644 --- a/src/Composer/Capability/DevToolsCommandProvider.php +++ b/src/Composer/Capability/DevToolsCommandProvider.php @@ -24,6 +24,7 @@ use FastForward\DevTools\Command\CopyLicenseCommand; use FastForward\DevTools\Command\DependenciesCommand; use FastForward\DevTools\Command\DocsCommand; +use FastForward\DevTools\Command\GitAttributesCommand; use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\PhpDocCommand; use FastForward\DevTools\Command\RefactorCommand; @@ -62,6 +63,7 @@ public function getCommands() new WikiCommand(), new SyncCommand(), new GitIgnoreCommand(), + new GitAttributesCommand(), new SkillsCommand(), new CopyLicenseCommand(), ]; diff --git a/src/GitAttributes/CandidateProvider.php b/src/GitAttributes/CandidateProvider.php new file mode 100644 index 0000000..010f849 --- /dev/null +++ b/src/GitAttributes/CandidateProvider.php @@ -0,0 +1,78 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +/** + * Provides the canonical list of candidate paths for export-ignore rules. + * + * This class defines the baseline set of files and directories that should + * typically be excluded from Composer package archives. The list is organized + * into folders and files groups for deterministic ordering. + */ +final class CandidateProvider implements CandidateProviderInterface +{ + /** + * @return list Folders that are candidates for export-ignore + */ + public function folders(): array + { + return [ + '/.github/', + '/.idea/', + '/.vscode/', + '/benchmarks/', + '/build/', + '/coverage/', + '/docs/', + '/examples/', + '/fixtures/', + '/scripts/', + '/tests/', + '/tools/', + ]; + } + + /** + * @return list Files that are candidates for export-ignore + */ + public function files(): array + { + return [ + '/.editorconfig', + '/.gitattributes', + '/.gitignore', + '/.gitmodules', + '/CODE_OF_CONDUCT.md', + '/CONTRIBUTING.md', + '/Makefile', + '/phpunit.xml.dist', + '/README.md', + ]; + } + + /** + * Returns all candidates as a combined list with folders first, then files. + * + * @return list All candidates in deterministic order + */ + public function all(): array + { + return [...$this->folders(), ...$this->files()]; + } +} diff --git a/src/GitAttributes/CandidateProviderInterface.php b/src/GitAttributes/CandidateProviderInterface.php new file mode 100644 index 0000000..666b0c8 --- /dev/null +++ b/src/GitAttributes/CandidateProviderInterface.php @@ -0,0 +1,50 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +/** + * Provides the canonical list of candidate paths for export-ignore rules. + * + * This interface defines the contract for classes that provide the baseline + * set of files and directories that should typically be excluded from + * Composer package archives. + */ +interface CandidateProviderInterface +{ + /** + * Returns the list of folder paths that are candidates for export-ignore. + * + * @return list Folder paths in canonical form (e.g., "/.github/") + */ + public function folders(): array; + + /** + * Returns the list of file paths that are candidates for export-ignore. + * + * @return list File paths in canonical form (e.g., "/.editorconfig") + */ + public function files(): array; + + /** + * Returns all candidates as a combined list with folders first, then files. + * + * @return list All candidates in deterministic order + */ + public function all(): array; +} diff --git a/src/GitAttributes/ExistenceChecker.php b/src/GitAttributes/ExistenceChecker.php new file mode 100644 index 0000000..67c23b8 --- /dev/null +++ b/src/GitAttributes/ExistenceChecker.php @@ -0,0 +1,97 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +use Symfony\Component\Filesystem\Filesystem; + +/** + * Checks the existence of files and directories in a given base path. + * + * This class determines which candidate paths from the canonical list + * actually exist in the target repository, enabling selective export-ignore rules. + */ +final readonly class ExistenceChecker implements ExistenceCheckerInterface +{ + private string $basePath; + + /** + * @param string $basePath The base directory to check paths against + * @param Filesystem $filesystem + */ + public function __construct( + string $basePath, + private Filesystem $filesystem = new Filesystem() + ) { + $this->basePath = rtrim($basePath, '/'); + } + + /** + * Checks if a path exists as a file or directory. + * + * @param string $path The path to check (e.g., "/.github/" or "/.editorconfig") + * + * @return bool True if the path exists as a file or directory + */ + public function exists(string $path): bool + { + $fullPath = $this->basePath . $path; + + return $this->filesystem->exists($fullPath); + } + + /** + * Filters a list of paths to only those that exist. + * + * @param list $paths The paths to filter + * + * @return list Only the paths that exist + */ + public function filterExisting(array $paths): array + { + return array_values(array_filter($paths, $this->exists(...))); + } + + /** + * Checks if a path is a directory. + * + * @param string $path The path to check (e.g., "/.github/") + * + * @return bool True if the path exists and is a directory + */ + public function isDirectory(string $path): bool + { + $fullPath = $this->basePath . $path; + + return is_dir($fullPath); + } + + /** + * Checks if a path is a file. + * + * @param string $path The path to check (e.g., "/.editorconfig") + * + * @return bool True if the path exists and is a file + */ + public function isFile(string $path): bool + { + $fullPath = $this->basePath . $path; + + return is_file($fullPath); + } +} diff --git a/src/GitAttributes/ExistenceCheckerInterface.php b/src/GitAttributes/ExistenceCheckerInterface.php new file mode 100644 index 0000000..6ac9242 --- /dev/null +++ b/src/GitAttributes/ExistenceCheckerInterface.php @@ -0,0 +1,64 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +/** + * Checks the existence of files and directories in a given base path. + * + * This interface defines the contract for determining which candidate + * paths actually exist in the target repository. + */ +interface ExistenceCheckerInterface +{ + /** + * Checks if a path exists as a file or directory. + * + * @param string $path The path to check (e.g., "/.github/" or "/.editorconfig") + * + * @return bool True if the path exists as a file or directory + */ + public function exists(string $path): bool; + + /** + * Filters a list of paths to only those that exist. + * + * @param list $paths The paths to filter + * + * @return list Only the paths that exist + */ + public function filterExisting(array $paths): array; + + /** + * Checks if a path is a directory. + * + * @param string $path The path to check (e.g., "/.github/") + * + * @return bool True if the path exists and is a directory + */ + public function isDirectory(string $path): bool; + + /** + * Checks if a path is a file. + * + * @param string $path The path to check (e.g., "/.editorconfig") + * + * @return bool True if the path exists and is a file + */ + public function isFile(string $path): bool; +} diff --git a/src/GitAttributes/Merger.php b/src/GitAttributes/Merger.php new file mode 100644 index 0000000..d64d93e --- /dev/null +++ b/src/GitAttributes/Merger.php @@ -0,0 +1,159 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +use function Safe\file_get_contents; +use function Safe\file_put_contents; + +/** + * Reads, merges, and writes .gitattributes files with export-ignore rules. + * + * This class manages the .gitattributes file by preserving existing custom + * entries while adding or updating export-ignore rules for known candidate + * paths. It separates managed export-ignore entries from custom entries. + */ +final readonly class Merger implements MergerInterface +{ + private const string MANAGED_START = '# << dev-tools:managed export-ignore'; + + private const string MANAGED_END = '# >> dev-tools:managed export-ignore'; + + /** + * @param string $path The path to the .gitattributes file + */ + public function __construct( + private string $path = '.gitattributes' + ) {} + + /** + * Reads the current .gitattributes content. + * + * @return string The raw file content + */ + public function read(): string + { + if (! file_exists($this->path)) { + return ''; + } + + return file_get_contents($this->path); + } + + /** + * Merges the managed export-ignore entries with existing .gitattributes content. + * + * This method: + * 1. Extracts existing custom entries (outside the managed block) + * 2. Adds the new export-ignore entries for existing paths + * 3. Orders them: folders first, then files, alphabetically sorted + * 4. Reconstructs the file with the managed block + * + * @param list $exportIgnoreEntries The export-ignore entries to manage + * + * @return string The merged .gitattributes content + */ + public function merge(array $exportIgnoreEntries): string + { + $existingContent = $this->read(); + $customEntries = $this->extractCustomEntries($existingContent); + + $managedContent = $this->renderManagedBlock($exportIgnoreEntries); + + $lines = []; + + if ('' !== $customEntries) { + $lines[] = $customEntries; + $lines[] = ''; + } + + $lines[] = self::MANAGED_START; + $lines[] = $managedContent; + $lines[] = self::MANAGED_END; + + return implode("\n", array_filter($lines, static fn(string $line): bool => '' !== $line)); + } + + /** + * Writes the merged content to the .gitattributes file. + * + * @param string $content The merged content to write + * + * @return void + */ + public function write(string $content): void + { + file_put_contents($this->path, $content . "\n"); + } + + /** + * Extracts custom entries that are outside the managed block. + * + * @param string $content The full .gitattributes content + * + * @return string The custom entries (outside managed block) + */ + private function extractCustomEntries(string $content): string + { + if ('' === $content) { + return ''; + } + + $startPos = strpos($content, self::MANAGED_START); + $endPos = strpos($content, self::MANAGED_END); + + if (false === $startPos && false === $endPos) { + return trim($content); + } + + if (false !== $startPos && false !== $endPos) { + $before = substr($content, 0, $startPos); + $after = substr($content, $endPos + \strlen(self::MANAGED_END)); + + return trim($before . "\n" . $after); + } + + if (false !== $startPos) { + return trim(substr($content, 0, $startPos)); + } + + return trim(substr($content, $endPos + \strlen(self::MANAGED_END))); + } + + /** + * Renders the managed export-ignore block content. + * + * @param list $entries The export-ignore entries to render + * + * @return string The rendered block content + */ + private function renderManagedBlock(array $entries): string + { + if ([] === $entries) { + return ''; + } + + $lines = []; + + foreach ($entries as $entry) { + $lines[] = $entry . ' export-ignore'; + } + + return implode("\n", $lines); + } +} diff --git a/src/GitAttributes/MergerInterface.php b/src/GitAttributes/MergerInterface.php new file mode 100644 index 0000000..3fe6b05 --- /dev/null +++ b/src/GitAttributes/MergerInterface.php @@ -0,0 +1,54 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +/** + * Merges export-ignore entries with existing .gitattributes content. + * + * This interface defines the contract for managing .gitattributes files, + * specifically handling the merging of canonical export-ignore rules with + * existing custom entries while preserving the managed block structure. + */ +interface MergerInterface +{ + /** + * Reads the current .gitattributes content. + * + * @return string The raw file content + */ + public function read(): string; + + /** + * Merges the managed export-ignore entries with existing .gitattributes content. + * + * @param list $exportIgnoreEntries The export-ignore entries to manage + * + * @return string The merged .gitattributes content + */ + public function merge(array $exportIgnoreEntries): string; + + /** + * Writes the merged content to the .gitattributes file. + * + * @param string $content The merged content to write + * + * @return void + */ + public function write(string $content): void; +} diff --git a/tests/Command/SyncCommandTest.php b/tests/Command/SyncCommandTest.php index 683fddf..fdd215a 100644 --- a/tests/Command/SyncCommandTest.php +++ b/tests/Command/SyncCommandTest.php @@ -20,9 +20,12 @@ use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\SyncCommand; +use FastForward\DevTools\GitAttributes\CandidateProvider; +use FastForward\DevTools\GitAttributes\ExistenceChecker; +use FastForward\DevTools\GitAttributes\Merger as GitAttributesMerger; use FastForward\DevTools\GitIgnore\Classifier; use FastForward\DevTools\GitIgnore\GitIgnore; -use FastForward\DevTools\GitIgnore\Merger; +use FastForward\DevTools\GitIgnore\Merger as GitIgnoreMerger; use FastForward\DevTools\GitIgnore\Reader; use FastForward\DevTools\GitIgnore\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -35,9 +38,12 @@ #[UsesClass(Reader::class)] #[UsesClass(GitIgnore::class)] #[UsesClass(Classifier::class)] -#[UsesClass(Merger::class)] +#[UsesClass(GitIgnoreMerger::class)] #[UsesClass(Writer::class)] #[UsesClass(GitIgnoreCommand::class)] +#[UsesClass(CandidateProvider::class)] +#[UsesClass(ExistenceChecker::class)] +#[UsesClass(GitAttributesMerger::class)] final class SyncCommandTest extends AbstractCommandTestCase { use ProphecyTrait; @@ -63,7 +69,7 @@ protected function getCommandName(): string */ protected function getCommandDescription(): string { - return 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, and .editorconfig in the root project.'; + return 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and .gitattributes in the root project.'; } /** @@ -71,7 +77,7 @@ protected function getCommandDescription(): string */ protected function getCommandHelp(): string { - return 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, and ensures .editorconfig is present and up to date.'; + return 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.'; } /** diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 9067fdd..009885d 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -23,6 +23,7 @@ use FastForward\DevTools\Command\CopyLicenseCommand; use FastForward\DevTools\Command\DependenciesCommand; use FastForward\DevTools\Command\DocsCommand; +use FastForward\DevTools\Command\GitAttributesCommand; use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\SyncCommand; use FastForward\DevTools\Command\SkillsCommand; @@ -34,7 +35,10 @@ use FastForward\DevTools\Command\TestsCommand; use FastForward\DevTools\Command\WikiCommand; use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider; -use FastForward\DevTools\GitIgnore\Merger; +use FastForward\DevTools\GitAttributes\CandidateProvider; +use FastForward\DevTools\GitAttributes\ExistenceChecker; +use FastForward\DevTools\GitAttributes\Merger as GitAttributesMerger; +use FastForward\DevTools\GitIgnore\Merger as GitIgnoreMerger; use FastForward\DevTools\GitIgnore\Writer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -53,10 +57,14 @@ #[UsesClass(WikiCommand::class)] #[UsesClass(SyncCommand::class)] #[UsesClass(GitIgnoreCommand::class)] +#[UsesClass(GitAttributesCommand::class)] #[UsesClass(SkillsCommand::class)] #[UsesClass(CopyLicenseCommand::class)] #[UsesClass(SkillsSynchronizer::class)] -#[UsesClass(Merger::class)] +#[UsesClass(CandidateProvider::class)] +#[UsesClass(ExistenceChecker::class)] +#[UsesClass(GitAttributesMerger::class)] +#[UsesClass(GitIgnoreMerger::class)] #[UsesClass(Writer::class)] final class DevToolsCommandProviderTest extends TestCase { @@ -89,6 +97,7 @@ public function getCommandsWillReturnAllSupportedCommandsInExpectedOrder(): void new WikiCommand(), new SyncCommand(), new GitIgnoreCommand(), + new GitAttributesCommand(), new SkillsCommand(), new CopyLicenseCommand(), ], From b9598a205b1153c33774e9cc54616b7884c3c1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Fri, 10 Apr 2026 11:19:24 -0300 Subject: [PATCH 2/6] feat(gitattributes): add GitAttributesCommand to manage export-ignore rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- src/Command/GitAttributesCommand.php | 116 +++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/Command/GitAttributesCommand.php diff --git a/src/Command/GitAttributesCommand.php b/src/Command/GitAttributesCommand.php new file mode 100644 index 0000000..4d62de2 --- /dev/null +++ b/src/Command/GitAttributesCommand.php @@ -0,0 +1,116 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command; + +use FastForward\DevTools\GitAttributes\CandidateProvider; +use FastForward\DevTools\GitAttributes\CandidateProviderInterface; +use FastForward\DevTools\GitAttributes\ExistenceChecker; +use FastForward\DevTools\GitAttributes\Merger; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +/** + * Provides functionality to manage .gitattributes export-ignore rules. + * + * This command adds export-ignore entries for repository-only files and directories + * to keep them out of Composer package archives. + */ +final class GitAttributesCommand extends AbstractCommand +{ + /** + * Creates a new GitAttributesCommand instance. + * + * @param Filesystem|null $filesystem the filesystem component + * @param CandidateProviderInterface|null $candidateProvider the candidate provider + */ + public function __construct( + ?Filesystem $filesystem = null, + private readonly ?CandidateProviderInterface $candidateProvider = new CandidateProvider() + ) { + parent::__construct($filesystem); + } + + /** + * Configures the current command. + * + * This method MUST define the name, description, and help text for the command. + * + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('Synchronizing .gitattributes export-ignore rules...'); + + $basePath = $this->getCurrentWorkingDirectory(); + + /** @var ExistenceChecker $checker */ + $checker = new ExistenceChecker($basePath, $this->filesystem); + + $existingFolders = $checker->filterExisting($this->candidateProvider->folders()); + $existingFiles = $checker->filterExisting($this->candidateProvider->files()); + + sort($existingFolders, \SORT_STRING); + sort($existingFiles, \SORT_STRING); + + $entries = [...$existingFolders, ...$existingFiles]; + + if ([] === $entries) { + $output->writeln( + 'No candidate paths found in repository. Skipping .gitattributes sync.' + ); + + return self::SUCCESS; + } + + $gitattributesPath = Path::join($basePath, '.gitattributes'); + $merger = new Merger($gitattributesPath); + + $content = $merger->merge($entries); + $merger->write($content); + + $output->writeln(\sprintf( + 'Added %d export-ignore entries to .gitattributes.', + \count($entries) + )); + + return self::SUCCESS; + } + + /** + * Configures the current command. + * + * This method MUST define the name, description, and help text for the command. + * + * @return void + */ + protected function configure(): void + { + $this + ->setName('gitattributes') + ->setDescription('Manages .gitattributes export-ignore rules for leaner package archives.') + ->setHelp( + 'This command adds export-ignore entries for repository-only files and directories ' + . 'to keep them out of Composer package archives. Only paths that exist in the ' + . 'repository are added, and existing custom rules are preserved.' + ); + } +} From 439ec4d40e6077a0da4590cce0021dde23d5252d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Fri, 10 Apr 2026 12:24:08 -0300 Subject: [PATCH 3/6] feat: Introduce GitAttributes management with Reader, Merger, and Writer implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitattributes | 27 +- composer.json | 12 + src/Command/GitAttributesCommand.php | 119 +++++-- src/GitAttributes/CandidateProvider.php | 11 + src/GitAttributes/ExistenceChecker.php | 45 +-- .../ExistenceCheckerInterface.php | 12 +- src/GitAttributes/ExportIgnoreFilter.php | 79 +++++ .../ExportIgnoreFilterInterface.php | 39 +++ src/GitAttributes/Merger.php | 315 +++++++++++++----- src/GitAttributes/MergerInterface.php | 25 +- src/GitAttributes/Reader.php | 46 +++ src/GitAttributes/ReaderInterface.php | 37 ++ src/GitAttributes/Writer.php | 178 ++++++++++ src/GitAttributes/WriterInterface.php | 39 +++ tests/Command/AbstractCommandTestCase.php | 2 + tests/Command/GitAttributesCommandTest.php | 250 ++++++++++++++ tests/Command/SyncCommandTest.php | 2 + .../DevToolsCommandProviderTest.php | 6 + .../GitAttributes/ExportIgnoreFilterTest.php | 57 ++++ tests/GitAttributes/MergerTest.php | 157 +++++++++ tests/GitAttributes/ReaderTest.php | 60 ++++ tests/GitAttributes/WriterTest.php | 92 +++++ 22 files changed, 1448 insertions(+), 162 deletions(-) create mode 100644 src/GitAttributes/ExportIgnoreFilter.php create mode 100644 src/GitAttributes/ExportIgnoreFilterInterface.php create mode 100644 src/GitAttributes/Reader.php create mode 100644 src/GitAttributes/ReaderInterface.php create mode 100644 src/GitAttributes/Writer.php create mode 100644 src/GitAttributes/WriterInterface.php create mode 100644 tests/Command/GitAttributesCommandTest.php create mode 100644 tests/GitAttributes/ExportIgnoreFilterTest.php create mode 100644 tests/GitAttributes/MergerTest.php create mode 100644 tests/GitAttributes/ReaderTest.php create mode 100644 tests/GitAttributes/WriterTest.php diff --git a/.gitattributes b/.gitattributes index 433ec33..39539fd 100755 --- a/.gitattributes +++ b/.gitattributes @@ -1,20 +1,9 @@ -* text=auto -/.agents/ export-ignore -/.github/ export-ignore -/docs/ export-ignore -/tests/ export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/.gitmodules export-ignore -AGENTS.md export-ignore -# << dev-tools:managed export-ignore -/.github/ export-ignore -/.vscode/ export-ignore -/docs/ export-ignore -/tests/ export-ignore -/.editorconfig export-ignore +* text=auto +/.github/ export-ignore +/.vscode/ export-ignore +/docs/ export-ignore +/tests/ export-ignore /.gitattributes export-ignore -/.gitignore export-ignore -/.gitmodules export-ignore -/README.md export-ignore -# >> dev-tools:managed export-ignore +/.gitmodules export-ignore +/AGENTS.md export-ignore +/README.md export-ignore diff --git a/composer.json b/composer.json index b56e328..d07278d 100644 --- a/composer.json +++ b/composer.json @@ -85,6 +85,18 @@ "dev-main": "1.x-dev" }, "class": "FastForward\\DevTools\\Composer\\Plugin", + "gitattributes": { + "keep-in-export": [ + "/.agents/", + "/.editorconfig", + "/.gitignore", + "/.php-cs-fixer.dist.php", + "/ecs.php", + "/grumphp.yml", + "/phpunit.xml", + "/rector.php" + ] + }, "grumphp": { "config-default-path": "grumphp.yml" } diff --git a/src/Command/GitAttributesCommand.php b/src/Command/GitAttributesCommand.php index 4d62de2..5ed12fc 100644 --- a/src/Command/GitAttributesCommand.php +++ b/src/Command/GitAttributesCommand.php @@ -21,7 +21,15 @@ use FastForward\DevTools\GitAttributes\CandidateProvider; use FastForward\DevTools\GitAttributes\CandidateProviderInterface; use FastForward\DevTools\GitAttributes\ExistenceChecker; +use FastForward\DevTools\GitAttributes\ExistenceCheckerInterface; +use FastForward\DevTools\GitAttributes\ExportIgnoreFilter; +use FastForward\DevTools\GitAttributes\ExportIgnoreFilterInterface; use FastForward\DevTools\GitAttributes\Merger; +use FastForward\DevTools\GitAttributes\MergerInterface; +use FastForward\DevTools\GitAttributes\Reader; +use FastForward\DevTools\GitAttributes\ReaderInterface; +use FastForward\DevTools\GitAttributes\Writer; +use FastForward\DevTools\GitAttributes\WriterInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Filesystem; @@ -35,17 +43,56 @@ */ final class GitAttributesCommand extends AbstractCommand { + private const string EXTRA_NAMESPACE = 'gitattributes'; + + private const string EXTRA_KEEP_IN_EXPORT = 'keep-in-export'; + + private const string EXTRA_NO_EXPORT_IGNORE = 'no-export-ignore'; + + private readonly WriterInterface $writer; + /** * Creates a new GitAttributesCommand instance. * * @param Filesystem|null $filesystem the filesystem component - * @param CandidateProviderInterface|null $candidateProvider the candidate provider + * @param CandidateProviderInterface $candidateProvider the candidate provider + * @param ExistenceCheckerInterface $existenceChecker the repository path existence checker + * @param ExportIgnoreFilterInterface $exportIgnoreFilter the configured candidate filter + * @param MergerInterface $merger the merger component + * @param ReaderInterface $reader the reader component + * @param WriterInterface|null $writer the writer component */ public function __construct( ?Filesystem $filesystem = null, - private readonly ?CandidateProviderInterface $candidateProvider = new CandidateProvider() + private readonly CandidateProviderInterface $candidateProvider = new CandidateProvider(), + private readonly ExistenceCheckerInterface $existenceChecker = new ExistenceChecker(), + private readonly ExportIgnoreFilterInterface $exportIgnoreFilter = new ExportIgnoreFilter(), + private readonly MergerInterface $merger = new Merger(), + private readonly ReaderInterface $reader = new Reader(), + ?WriterInterface $writer = null, ) { parent::__construct($filesystem); + $this->writer = $writer ?? new Writer($this->filesystem); + } + + /** + * Configures the current command. + * + * This method MUST define the name, description, and help text for the command. + * + * @return void + */ + protected function configure(): void + { + $this + ->setName('gitattributes') + ->setDescription('Manages .gitattributes export-ignore rules for leaner package archives.') + ->setHelp( + 'This command adds export-ignore entries for repository-only files and directories ' + . 'to keep them out of Composer package archives. 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.' + ); } /** @@ -61,15 +108,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('Synchronizing .gitattributes export-ignore rules...'); $basePath = $this->getCurrentWorkingDirectory(); + $keepInExportPaths = $this->configuredKeepInExportPaths(); - /** @var ExistenceChecker $checker */ - $checker = new ExistenceChecker($basePath, $this->filesystem); - - $existingFolders = $checker->filterExisting($this->candidateProvider->folders()); - $existingFiles = $checker->filterExisting($this->candidateProvider->files()); + $folderCandidates = $this->exportIgnoreFilter->filter($this->candidateProvider->folders(), $keepInExportPaths); + $fileCandidates = $this->exportIgnoreFilter->filter($this->candidateProvider->files(), $keepInExportPaths); - sort($existingFolders, \SORT_STRING); - sort($existingFiles, \SORT_STRING); + $existingFolders = $this->existenceChecker->filterExisting($basePath, $folderCandidates); + $existingFiles = $this->existenceChecker->filterExisting($basePath, $fileCandidates); $entries = [...$existingFolders, ...$existingFiles]; @@ -82,10 +127,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $gitattributesPath = Path::join($basePath, '.gitattributes'); - $merger = new Merger($gitattributesPath); - - $content = $merger->merge($entries); - $merger->write($content); + $existingContent = $this->reader->read($gitattributesPath); + $content = $this->merger->merge($existingContent, $entries, $keepInExportPaths); + $this->writer->write($gitattributesPath, $content); $output->writeln(\sprintf( 'Added %d export-ignore entries to .gitattributes.', @@ -96,21 +140,46 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * Configures the current command. + * Resolves the consumer-defined paths that MUST stay in exported archives. * - * This method MUST define the name, description, and help text for the command. + * The preferred configuration key is "extra.gitattributes.keep-in-export". + * The alternate "extra.gitattributes.no-export-ignore" key remains + * supported as a compatibility alias. * - * @return void + * @return list the configured keep-in-export paths */ - protected function configure(): void + private function configuredKeepInExportPaths(): array { - $this - ->setName('gitattributes') - ->setDescription('Manages .gitattributes export-ignore rules for leaner package archives.') - ->setHelp( - 'This command adds export-ignore entries for repository-only files and directories ' - . 'to keep them out of Composer package archives. Only paths that exist in the ' - . 'repository are added, and existing custom rules are preserved.' - ); + $extra = $this->requireComposer() + ->getPackage() + ->getExtra(); + + $gitattributesConfig = $extra[self::EXTRA_NAMESPACE] ?? null; + + if (! \is_array($gitattributesConfig)) { + return []; + } + + $configuredPaths = []; + + foreach ([self::EXTRA_KEEP_IN_EXPORT, self::EXTRA_NO_EXPORT_IGNORE] as $key) { + $values = $gitattributesConfig[$key] ?? []; + + if (\is_string($values)) { + $values = [$values]; + } + + if (! \is_array($values)) { + continue; + } + + foreach ($values as $value) { + if (\is_string($value)) { + $configuredPaths[] = $value; + } + } + } + + return array_values(array_unique($configuredPaths)); } } diff --git a/src/GitAttributes/CandidateProvider.php b/src/GitAttributes/CandidateProvider.php index 010f849..facae9f 100644 --- a/src/GitAttributes/CandidateProvider.php +++ b/src/GitAttributes/CandidateProvider.php @@ -33,6 +33,7 @@ final class CandidateProvider implements CandidateProviderInterface public function folders(): array { return [ + '/.devcontainer/', '/.github/', '/.idea/', '/.vscode/', @@ -63,6 +64,16 @@ public function files(): array '/Makefile', '/phpunit.xml.dist', '/README.md', + '/AGENTS.md', + '/GEMINI.md', + '/Dockerfile', + '/.dockerignore', + '/.env', + '/docker-compose.yml', + '/docker-compose.override.yml', + '/docker-stack.yml', + '/compose.yml', + '/compose.override.yml', ]; } diff --git a/src/GitAttributes/ExistenceChecker.php b/src/GitAttributes/ExistenceChecker.php index 67c23b8..ae56256 100644 --- a/src/GitAttributes/ExistenceChecker.php +++ b/src/GitAttributes/ExistenceChecker.php @@ -28,70 +28,75 @@ */ final readonly class ExistenceChecker implements ExistenceCheckerInterface { - private string $basePath; - /** - * @param string $basePath The base directory to check paths against * @param Filesystem $filesystem */ public function __construct( - string $basePath, private Filesystem $filesystem = new Filesystem() - ) { - $this->basePath = rtrim($basePath, '/'); - } + ) {} /** * Checks if a path exists as a file or directory. * + * @param string $basePath the repository base path used to resolve the candidate * @param string $path The path to check (e.g., "/.github/" or "/.editorconfig") * * @return bool True if the path exists as a file or directory */ - public function exists(string $path): bool + public function exists(string $basePath, string $path): bool { - $fullPath = $this->basePath . $path; - - return $this->filesystem->exists($fullPath); + return $this->filesystem->exists($this->absolutePath($basePath, $path)); } /** * Filters a list of paths to only those that exist. * + * @param string $basePath the repository base path used to resolve the candidates * @param list $paths The paths to filter * * @return list Only the paths that exist */ - public function filterExisting(array $paths): array + public function filterExisting(string $basePath, array $paths): array { - return array_values(array_filter($paths, $this->exists(...))); + return array_values(array_filter($paths, fn(string $path): bool => $this->exists($basePath, $path))); } /** * Checks if a path is a directory. * + * @param string $basePath the repository base path used to resolve the candidate * @param string $path The path to check (e.g., "/.github/") * * @return bool True if the path exists and is a directory */ - public function isDirectory(string $path): bool + public function isDirectory(string $basePath, string $path): bool { - $fullPath = $this->basePath . $path; - - return is_dir($fullPath); + return is_dir($this->absolutePath($basePath, $path)); } /** * Checks if a path is a file. * + * @param string $basePath the repository base path used to resolve the candidate * @param string $path The path to check (e.g., "/.editorconfig") * * @return bool True if the path exists and is a file */ - public function isFile(string $path): bool + public function isFile(string $basePath, string $path): bool { - $fullPath = $this->basePath . $path; + return is_file($this->absolutePath($basePath, $path)); + } - return is_file($fullPath); + /** + * Resolves a candidate path against the repository base path. + * + * @param string $basePath the repository base path + * @param string $path the candidate path in canonical form + * + * @return string the absolute path used for filesystem checks + */ + private function absolutePath(string $basePath, string $path): string + { + return rtrim($basePath, '/\\') . $path; } } diff --git a/src/GitAttributes/ExistenceCheckerInterface.php b/src/GitAttributes/ExistenceCheckerInterface.php index 6ac9242..9d24fe9 100644 --- a/src/GitAttributes/ExistenceCheckerInterface.php +++ b/src/GitAttributes/ExistenceCheckerInterface.php @@ -29,36 +29,40 @@ interface ExistenceCheckerInterface /** * Checks if a path exists as a file or directory. * + * @param string $basePath the repository base path used to resolve the candidate * @param string $path The path to check (e.g., "/.github/" or "/.editorconfig") * * @return bool True if the path exists as a file or directory */ - public function exists(string $path): bool; + public function exists(string $basePath, string $path): bool; /** * Filters a list of paths to only those that exist. * + * @param string $basePath the repository base path used to resolve the candidates * @param list $paths The paths to filter * * @return list Only the paths that exist */ - public function filterExisting(array $paths): array; + public function filterExisting(string $basePath, array $paths): array; /** * Checks if a path is a directory. * + * @param string $basePath the repository base path used to resolve the candidate * @param string $path The path to check (e.g., "/.github/") * * @return bool True if the path exists and is a directory */ - public function isDirectory(string $path): bool; + public function isDirectory(string $basePath, string $path): bool; /** * Checks if a path is a file. * + * @param string $basePath the repository base path used to resolve the candidate * @param string $path The path to check (e.g., "/.editorconfig") * * @return bool True if the path exists and is a file */ - public function isFile(string $path): bool; + public function isFile(string $basePath, string $path): bool; } diff --git a/src/GitAttributes/ExportIgnoreFilter.php b/src/GitAttributes/ExportIgnoreFilter.php new file mode 100644 index 0000000..d4ff520 --- /dev/null +++ b/src/GitAttributes/ExportIgnoreFilter.php @@ -0,0 +1,79 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +use function Safe\preg_replace; + +/** + * Filters export-ignore candidates using normalized path comparisons. + * + * This filter SHALL compare configured keep-in-export paths against canonical + * candidates while ignoring leading and trailing slash differences. It MUST + * preserve the original candidate ordering in the filtered result. + */ +final class ExportIgnoreFilter implements ExportIgnoreFilterInterface +{ + /** + * Filters export-ignore candidates using the configured keep-in-export paths. + * + * @param list $candidates the canonical candidate paths + * @param list $keepInExportPaths the paths that MUST remain exportable + * + * @return list the filtered export-ignore candidates + */ + public function filter(array $candidates, array $keepInExportPaths): array + { + $keptPathLookup = []; + + foreach ($keepInExportPaths as $path) { + $normalizedPath = $this->normalizePath($path); + + if ('' === $normalizedPath) { + continue; + } + + $keptPathLookup[$normalizedPath] = true; + } + + return array_values(array_filter( + $candidates, + fn(string $candidate): bool => ! isset($keptPathLookup[$this->normalizePath($candidate)]) + )); + } + + /** + * Normalizes a configured path for stable matching. + * + * @param string $path the raw path from candidates or Composer extra config + * + * @return string the normalized path used for comparisons + */ + private function normalizePath(string $path): string + { + $trimmedPath = trim($path); + + if ('' === $trimmedPath) { + return ''; + } + + $normalizedPath = preg_replace('#/+#', '/', '/' . ltrim($trimmedPath, '/')) ?? $trimmedPath; + + return '/' === $normalizedPath ? $normalizedPath : rtrim($normalizedPath, '/'); + } +} diff --git a/src/GitAttributes/ExportIgnoreFilterInterface.php b/src/GitAttributes/ExportIgnoreFilterInterface.php new file mode 100644 index 0000000..2ad3978 --- /dev/null +++ b/src/GitAttributes/ExportIgnoreFilterInterface.php @@ -0,0 +1,39 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +/** + * Filters canonical export-ignore candidates against consumer keep rules. + * + * Implementations MUST remove any candidate path explicitly configured to stay + * in the exported package archive, while preserving the order of the remaining + * candidates. + */ +interface ExportIgnoreFilterInterface +{ + /** + * Filters export-ignore candidates using the configured keep-in-export paths. + * + * @param list $candidates the canonical candidate paths + * @param list $keepInExportPaths the paths that MUST remain exportable + * + * @return list the filtered export-ignore candidates + */ + public function filter(array $candidates, array $keepInExportPaths): array; +} diff --git a/src/GitAttributes/Merger.php b/src/GitAttributes/Merger.php index d64d93e..1eb74ab 100644 --- a/src/GitAttributes/Merger.php +++ b/src/GitAttributes/Merger.php @@ -18,142 +18,307 @@ namespace FastForward\DevTools\GitAttributes; -use function Safe\file_get_contents; -use function Safe\file_put_contents; +use function Safe\preg_split; +use function Safe\preg_replace; +use function Safe\preg_match; /** - * Reads, merges, and writes .gitattributes files with export-ignore rules. + * Merges .gitattributes content with generated export-ignore rules. * - * This class manages the .gitattributes file by preserving existing custom - * entries while adding or updating export-ignore rules for known candidate - * paths. It separates managed export-ignore entries from custom entries. + * This class preserves existing custom entries while adding missing + * export-ignore rules for known candidate paths, deduplicates semantically + * equivalent entries, and sorts export-ignore rules with directories before + * files. */ -final readonly class Merger implements MergerInterface +final class Merger implements MergerInterface { - private const string MANAGED_START = '# << dev-tools:managed export-ignore'; - - private const string MANAGED_END = '# >> dev-tools:managed export-ignore'; - /** - * @param string $path The path to the .gitattributes file + * Merges generated export-ignore entries with existing .gitattributes content. + * + * This method: + * 1. Preserves custom user-defined entries in their original order + * 2. Adds missing generated export-ignore entries for existing paths + * 3. Deduplicates entries using normalized path comparison + * 4. Sorts export-ignore entries with directories before files + * + * @param string $existingContent The raw .gitattributes content currently stored. + * @param list $exportIgnoreEntries the export-ignore entries to manage + * @param list $keepInExportPaths the paths that MUST remain exported + * + * @return string The merged .gitattributes content */ - public function __construct( - private string $path = '.gitattributes' - ) {} + public function merge(string $existingContent, array $exportIgnoreEntries, array $keepInExportPaths = []): string + { + $nonExportIgnoreLines = []; + $seenNonExportIgnoreLines = []; + $exportIgnoreLines = []; + $keptExportLookup = $this->keepInExportLookup($keepInExportPaths); + $generatedDirectoryLookup = $this->generatedDirectoryLookup($exportIgnoreEntries); + + foreach ($this->parseExistingLines($existingContent) as $line) { + $normalizedLine = $this->normalizeLine($line); + + if ('' === $normalizedLine) { + continue; + } + + $pathSpec = $this->extractExportIgnorePathSpec($normalizedLine); + + if (null === $pathSpec) { + if (isset($seenNonExportIgnoreLines[$normalizedLine])) { + continue; + } + + $nonExportIgnoreLines[] = $normalizedLine; + $seenNonExportIgnoreLines[$normalizedLine] = true; + + continue; + } + + $pathKey = $this->normalizePathKey($pathSpec); + if (isset($keptExportLookup[$pathKey])) { + continue; + } + if (isset($exportIgnoreLines[$pathKey])) { + continue; + } + + $exportIgnoreLines[$pathKey] = [ + 'line' => $normalizedLine, + 'sort_key' => $this->sortKey($pathSpec), + 'is_directory' => str_ends_with($pathSpec, '/') || isset($generatedDirectoryLookup[$pathKey]), + ]; + } + + foreach ($exportIgnoreEntries as $entry) { + $trimmedEntry = trim($entry); + $pathKey = $this->normalizePathKey($trimmedEntry); + + if (isset($keptExportLookup[$pathKey])) { + continue; + } + + if (! isset($exportIgnoreLines[$pathKey])) { + $exportIgnoreLines[$pathKey] = [ + 'line' => $trimmedEntry . ' export-ignore', + 'sort_key' => $this->sortKey($trimmedEntry), + 'is_directory' => str_ends_with($trimmedEntry, '/'), + ]; + + continue; + } + + $exportIgnoreLines[$pathKey]['is_directory'] = $exportIgnoreLines[$pathKey]['is_directory'] + || str_ends_with($trimmedEntry, '/'); + } + + $sortedExportIgnoreLines = array_values($exportIgnoreLines); + + usort( + $sortedExportIgnoreLines, + static function (array $left, array $right): int { + if ($left['is_directory'] !== $right['is_directory']) { + return $left['is_directory'] ? -1 : 1; + } + + $naturalOrder = strnatcasecmp($left['sort_key'], $right['sort_key']); + + if (0 !== $naturalOrder) { + return $naturalOrder; + } + + return strcmp($left['sort_key'], $right['sort_key']); + } + ); + + return implode("\n", [...$nonExportIgnoreLines, ...array_column($sortedExportIgnoreLines, 'line')]); + } /** - * Reads the current .gitattributes content. + * Parses the raw .gitattributes content into trimmed non-empty lines. + * + * @param string $content The full .gitattributes content. * - * @return string The raw file content + * @return list the non-empty lines from the file */ - public function read(): string + private function parseExistingLines(string $content): array { - if (! file_exists($this->path)) { - return ''; + if ('' === $content) { + return []; } - return file_get_contents($this->path); + $lines = []; + + foreach (preg_split('/\R/', $content) ?: [] as $line) { + $trimmedLine = trim($line); + + if ('' === $trimmedLine) { + continue; + } + + $lines[] = $trimmedLine; + } + + return $lines; } /** - * Merges the managed export-ignore entries with existing .gitattributes content. - * - * This method: - * 1. Extracts existing custom entries (outside the managed block) - * 2. Adds the new export-ignore entries for existing paths - * 3. Orders them: folders first, then files, alphabetically sorted - * 4. Reconstructs the file with the managed block + * Builds a lookup table for paths that MUST stay in the exported archive. * - * @param list $exportIgnoreEntries The export-ignore entries to manage + * @param list $keepInExportPaths the configured keep-in-export paths * - * @return string The merged .gitattributes content + * @return array the normalized path lookup */ - public function merge(array $exportIgnoreEntries): string + private function keepInExportLookup(array $keepInExportPaths): array { - $existingContent = $this->read(); - $customEntries = $this->extractCustomEntries($existingContent); + $lookup = []; - $managedContent = $this->renderManagedBlock($exportIgnoreEntries); + foreach ($keepInExportPaths as $path) { + $normalizedPath = $this->normalizePathKey($path); - $lines = []; + if ('' === $normalizedPath) { + continue; + } - if ('' !== $customEntries) { - $lines[] = $customEntries; - $lines[] = ''; + $lookup[$normalizedPath] = true; } - $lines[] = self::MANAGED_START; - $lines[] = $managedContent; - $lines[] = self::MANAGED_END; - - return implode("\n", array_filter($lines, static fn(string $line): bool => '' !== $line)); + return $lookup; } /** - * Writes the merged content to the .gitattributes file. + * Builds a lookup table of generated directory candidates. * - * @param string $content The merged content to write + * @param list $exportIgnoreEntries the generated export-ignore path list * - * @return void + * @return array the normalized directory lookup */ - public function write(string $content): void + private function generatedDirectoryLookup(array $exportIgnoreEntries): array { - file_put_contents($this->path, $content . "\n"); + $lookup = []; + + foreach ($exportIgnoreEntries as $entry) { + $trimmedEntry = trim($entry); + + if (! str_ends_with($trimmedEntry, '/')) { + continue; + } + + $lookup[$this->normalizePathKey($trimmedEntry)] = true; + } + + return $lookup; } /** - * Extracts custom entries that are outside the managed block. + * Normalizes a .gitattributes line for deterministic comparison and output. * - * @param string $content The full .gitattributes content + * @param string $line the raw line to normalize * - * @return string The custom entries (outside managed block) + * @return string the normalized line */ - private function extractCustomEntries(string $content): string + private function normalizeLine(string $line): string { - if ('' === $content) { + $trimmedLine = trim($line); + + if ('' === $trimmedLine) { return ''; } - $startPos = strpos($content, self::MANAGED_START); - $endPos = strpos($content, self::MANAGED_END); - - if (false === $startPos && false === $endPos) { - return trim($content); + if (str_starts_with($trimmedLine, '#')) { + return $trimmedLine; } - if (false !== $startPos && false !== $endPos) { - $before = substr($content, 0, $startPos); - $after = substr($content, $endPos + \strlen(self::MANAGED_END)); + return preg_replace('/(?normalizePathSpec($pathSpec), '/'); } /** - * Renders the managed export-ignore block content. + * Normalizes a gitattributes path spec for sorting. * - * @param list $entries The export-ignore entries to render + * @param string $pathSpec the raw path spec to normalize * - * @return string The rendered block content + * @return string the normalized path spec */ - private function renderManagedBlock(array $entries): string + private function normalizePathSpec(string $pathSpec): string { - if ([] === $entries) { + $trimmedPathSpec = trim($pathSpec); + + if ('' === $trimmedPathSpec) { return ''; } - $lines = []; + $isDirectory = str_ends_with($trimmedPathSpec, '/'); + $normalizedPathSpec = preg_replace('#/+#', '/', '/' . ltrim($trimmedPathSpec, '/')) ?? $trimmedPathSpec; + $normalizedPathSpec = '/' === $normalizedPathSpec ? $normalizedPathSpec : rtrim($normalizedPathSpec, '/'); - foreach ($entries as $entry) { - $lines[] = $entry . ' export-ignore'; + if ($isDirectory && '/' !== $normalizedPathSpec) { + $normalizedPathSpec .= '/'; } - return implode("\n", $lines); + return $normalizedPathSpec; + } + + /** + * Normalizes a path spec for deduplication and keep-in-export matching. + * + * Literal root paths are compared without leading slash differences, while + * pattern-based specs preserve their original anchoring semantics. + * + * @param string $pathSpec the raw path spec to normalize + * + * @return string the normalized deduplication key + */ + private function normalizePathKey(string $pathSpec): string + { + $normalizedPathSpec = $this->normalizePathSpec($pathSpec); + + if ($this->isLiteralPathSpec($normalizedPathSpec)) { + return ltrim(rtrim($normalizedPathSpec, '/'), '/'); + } + + return $normalizedPathSpec; + } + + /** + * Determines whether a path spec is a literal path and not a glob pattern. + * + * @param string $pathSpec the normalized path spec to inspect + * + * @return bool true when the path spec is a literal path + */ + private function isLiteralPathSpec(string $pathSpec): bool + { + return ! str_contains($pathSpec, '*') + && ! str_contains($pathSpec, '?') + && ! str_contains($pathSpec, '[') + && ! str_contains($pathSpec, '{'); } } diff --git a/src/GitAttributes/MergerInterface.php b/src/GitAttributes/MergerInterface.php index 3fe6b05..f5477b7 100644 --- a/src/GitAttributes/MergerInterface.php +++ b/src/GitAttributes/MergerInterface.php @@ -23,32 +23,19 @@ * * This interface defines the contract for managing .gitattributes files, * specifically handling the merging of canonical export-ignore rules with - * existing custom entries while preserving the managed block structure. + * existing custom entries while removing obsolete generated markers and + * duplicate lines. */ interface MergerInterface { /** - * Reads the current .gitattributes content. - * - * @return string The raw file content - */ - public function read(): string; - - /** - * Merges the managed export-ignore entries with existing .gitattributes content. + * Merges generated export-ignore entries with existing .gitattributes content. * + * @param string $existingContent The current .gitattributes content. * @param list $exportIgnoreEntries The export-ignore entries to manage + * @param list $keepInExportPaths The paths that MUST remain exported * * @return string The merged .gitattributes content */ - public function merge(array $exportIgnoreEntries): string; - - /** - * Writes the merged content to the .gitattributes file. - * - * @param string $content The merged content to write - * - * @return void - */ - public function write(string $content): void; + public function merge(string $existingContent, array $exportIgnoreEntries, array $keepInExportPaths = []): string; } diff --git a/src/GitAttributes/Reader.php b/src/GitAttributes/Reader.php new file mode 100644 index 0000000..fa9ba16 --- /dev/null +++ b/src/GitAttributes/Reader.php @@ -0,0 +1,46 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +use function Safe\file_get_contents; + +/** + * Reads raw .gitattributes content from the filesystem. + * + * This reader SHALL return the complete textual contents of a .gitattributes + * file, and MUST yield an empty string when the file does not exist. + */ +final class Reader implements ReaderInterface +{ + /** + * Reads a .gitattributes file from the specified filesystem path. + * + * @param string $gitattributesPath The filesystem path to the .gitattributes file. + * + * @return string The raw .gitattributes content, or an empty string when absent. + */ + public function read(string $gitattributesPath): string + { + if (! file_exists($gitattributesPath)) { + return ''; + } + + return file_get_contents($gitattributesPath); + } +} diff --git a/src/GitAttributes/ReaderInterface.php b/src/GitAttributes/ReaderInterface.php new file mode 100644 index 0000000..4ee1cce --- /dev/null +++ b/src/GitAttributes/ReaderInterface.php @@ -0,0 +1,37 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +/** + * Defines the contract for reading .gitattributes files from persistent storage. + * + * Implementations MUST load the raw textual content from the provided path and + * SHALL return an empty string when the target file does not exist. + */ +interface ReaderInterface +{ + /** + * Reads the .gitattributes content from the specified filesystem path. + * + * @param string $gitattributesPath The filesystem path to the .gitattributes file. + * + * @return string The raw .gitattributes content, or an empty string when absent. + */ + public function read(string $gitattributesPath): string; +} diff --git a/src/GitAttributes/Writer.php b/src/GitAttributes/Writer.php new file mode 100644 index 0000000..c75a160 --- /dev/null +++ b/src/GitAttributes/Writer.php @@ -0,0 +1,178 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +use Symfony\Component\Filesystem\Filesystem; + +use function Safe\preg_split; + +/** + * Persists normalized .gitattributes content. + * + * This writer SHALL align attribute declarations using the longest path spec, + * write the provided textual content to the target path, and MUST append a + * final trailing line feed for deterministic formatting. + */ +final readonly class Writer implements WriterInterface +{ + /** + * @param Filesystem $filesystem the filesystem service responsible for writing the file + */ + public function __construct( + private Filesystem $filesystem + ) {} + + /** + * Writes the .gitattributes content to the specified filesystem path. + * + * @param string $gitattributesPath The filesystem path to the .gitattributes file. + * @param string $content The merged .gitattributes content to persist. + * + * @return void + */ + public function write(string $gitattributesPath, string $content): void + { + $this->filesystem->dumpFile($gitattributesPath, $this->format($content)); + } + + /** + * Formats .gitattributes content with aligned attribute columns. + * + * @param string $content The merged .gitattributes content to normalize. + * + * @return string + */ + private function format(string $content): string + { + $rows = []; + $maxPathSpecLength = 0; + + foreach (preg_split('/\R/', $content) ?: [] as $line) { + $trimmedLine = trim((string) $line); + + if ('' === $trimmedLine) { + $rows[] = [ + 'type' => 'raw', + 'line' => '', + ]; + + continue; + } + + if (str_starts_with($trimmedLine, '#')) { + $rows[] = [ + 'type' => 'raw', + 'line' => $trimmedLine, + ]; + + continue; + } + + $entry = $this->parseEntry($trimmedLine); + + if (null === $entry) { + $rows[] = [ + 'type' => 'raw', + 'line' => $trimmedLine, + ]; + + continue; + } + + $maxPathSpecLength = max($maxPathSpecLength, \strlen($entry['path_spec'])); + $rows[] = [ + 'type' => 'entry', + 'path_spec' => $entry['path_spec'], + 'attributes' => $entry['attributes'], + ]; + } + + $formattedLines = []; + + foreach ($rows as $row) { + if ('entry' !== $row['type']) { + $formattedLines[] = $row['line']; + + continue; + } + + $formattedLines[] = str_pad($row['path_spec'], $maxPathSpecLength + 1) . $row['attributes']; + } + + return implode("\n", $formattedLines) . "\n"; + } + + /** + * Parses a .gitattributes entry into its path spec and attribute segment. + * + * @param string $line The normalized .gitattributes line. + * + * @return array{path_spec: string, attributes: string}|null + */ + private function parseEntry(string $line): ?array + { + $separatorPosition = $this->firstUnescapedWhitespacePosition($line); + + if (null === $separatorPosition) { + return null; + } + + $pathSpec = substr($line, 0, $separatorPosition); + $attributes = ltrim(substr($line, $separatorPosition)); + + if ('' === $pathSpec || '' === $attributes) { + return null; + } + + return [ + 'path_spec' => $pathSpec, + 'attributes' => $attributes, + ]; + } + + /** + * Locates the first non-escaped whitespace separator in a line. + * + * @param string $line the line to inspect + * + * @return int|null + */ + private function firstUnescapedWhitespacePosition(string $line): ?int + { + $length = \strlen($line); + + for ($position = 0; $position < $length; ++$position) { + if (! \in_array($line[$position], [' ', "\t"], true)) { + continue; + } + + $backslashCount = 0; + + for ($index = $position - 1; $index >= 0 && '\\' === $line[$index]; --$index) { + ++$backslashCount; + } + + if (0 === $backslashCount % 2) { + return $position; + } + } + + return null; + } +} diff --git a/src/GitAttributes/WriterInterface.php b/src/GitAttributes/WriterInterface.php new file mode 100644 index 0000000..12fccd7 --- /dev/null +++ b/src/GitAttributes/WriterInterface.php @@ -0,0 +1,39 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitAttributes; + +/** + * Defines the contract for writing .gitattributes files to persistent storage. + * + * Implementations MUST write the provided content to the target path, SHOULD + * normalize attribute-column alignment for deterministic output, and SHALL + * ensure the resulting file ends with a trailing line feed. + */ +interface WriterInterface +{ + /** + * Writes the .gitattributes content to the specified filesystem path. + * + * @param string $gitattributesPath The filesystem path to the .gitattributes file. + * @param string $content The merged .gitattributes content to persist. + * + * @return void + */ + public function write(string $gitattributesPath, string $content): void; +} diff --git a/tests/Command/AbstractCommandTestCase.php b/tests/Command/AbstractCommandTestCase.php index 86f6cbf..99bde72 100644 --- a/tests/Command/AbstractCommandTestCase.php +++ b/tests/Command/AbstractCommandTestCase.php @@ -92,6 +92,8 @@ protected function setUp(): void ->willReturn('fast-forward/dev-tools'); $this->package->getDescription() ->willReturn('Fast Forward Dev Tools plugin'); + $this->package->getExtra() + ->willReturn([]); $this->composer->getPackage() ->willReturn($this->package->reveal()); diff --git a/tests/Command/GitAttributesCommandTest.php b/tests/Command/GitAttributesCommandTest.php new file mode 100644 index 0000000..9471bba --- /dev/null +++ b/tests/Command/GitAttributesCommandTest.php @@ -0,0 +1,250 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Command; + +use FastForward\DevTools\Command\GitAttributesCommand; +use FastForward\DevTools\GitAttributes\CandidateProviderInterface; +use FastForward\DevTools\GitAttributes\ExistenceCheckerInterface; +use FastForward\DevTools\GitAttributes\ExportIgnoreFilterInterface; +use FastForward\DevTools\GitAttributes\MergerInterface; +use FastForward\DevTools\GitAttributes\ReaderInterface; +use FastForward\DevTools\GitAttributes\WriterInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(GitAttributesCommand::class)] +final class GitAttributesCommandTest extends AbstractCommandTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $candidateProvider; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $existenceChecker; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $merger; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $exportIgnoreFilter; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $reader; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $writer; + + /** + * @return void + */ + protected function setUp(): void + { + $this->candidateProvider = $this->prophesize(CandidateProviderInterface::class); + $this->existenceChecker = $this->prophesize(ExistenceCheckerInterface::class); + $this->exportIgnoreFilter = $this->prophesize(ExportIgnoreFilterInterface::class); + $this->merger = $this->prophesize(MergerInterface::class); + $this->reader = $this->prophesize(ReaderInterface::class); + $this->writer = $this->prophesize(WriterInterface::class); + + parent::setUp(); + + $this->application->getInitialWorkingDirectory() + ->willReturn('/project'); + } + + /** + * @return GitAttributesCommand + */ + protected function getCommandClass(): GitAttributesCommand + { + return new GitAttributesCommand( + $this->filesystem->reveal(), + $this->candidateProvider->reveal(), + $this->existenceChecker->reveal(), + $this->exportIgnoreFilter->reveal(), + $this->merger->reveal(), + $this->reader->reveal(), + $this->writer->reveal(), + ); + } + + /** + * @return string + */ + protected function getCommandName(): string + { + return 'gitattributes'; + } + + /** + * @return string + */ + protected function getCommandDescription(): string + { + return 'Manages .gitattributes export-ignore rules for leaner package archives.'; + } + + /** + * @return string + */ + protected function getCommandHelp(): string + { + return 'This command adds export-ignore entries for repository-only files and directories to keep them out of Composer package archives. 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.'; + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnSuccessAndWriteMergedGitattributes(): void + { + $folders = ['/docs/', '/.github/']; + $files = ['/README.md', '/.editorconfig']; + $entries = ['/docs/', '/.github/', '/README.md', '/.editorconfig']; + + $this->candidateProvider->folders() + ->willReturn($folders); + $this->candidateProvider->files() + ->willReturn($files); + $this->exportIgnoreFilter->filter($folders, []) + ->willReturn($folders); + $this->exportIgnoreFilter->filter($files, []) + ->willReturn($files); + + $this->existenceChecker->filterExisting('/project', $folders) + ->willReturn($folders); + $this->existenceChecker->filterExisting('/project', $files) + ->willReturn($files); + + $this->reader->read('/project/.gitattributes') + ->willReturn("custom-entry\n"); + + $this->merger->merge("custom-entry\n", $entries, []) + ->willReturn("custom-entry\n/.github/ export-ignore"); + + $this->writer->write('/project/.gitattributes', "custom-entry\n/.github/ export-ignore") + ->shouldBeCalledOnce(); + + $this->output->writeln('Synchronizing .gitattributes export-ignore rules...') + ->shouldBeCalled(); + $this->output->writeln('Added 4 export-ignore entries to .gitattributes.') + ->shouldBeCalled(); + + self::assertSame(GitAttributesCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillRespectKeepInExportComposerConfiguration(): void + { + $folders = ['/docs/', '/.github/']; + $files = ['/README.md', '/.editorconfig']; + $keepInExportPaths = ['/README.md', '/.github/']; + $filteredFolders = ['/docs/']; + $filteredFiles = ['/.editorconfig']; + $entries = ['/docs/', '/.editorconfig']; + + $this->package->getExtra() + ->willReturn([ + 'gitattributes' => [ + 'keep-in-export' => $keepInExportPaths, + ], + ]); + + $this->candidateProvider->folders() + ->willReturn($folders); + $this->candidateProvider->files() + ->willReturn($files); + $this->exportIgnoreFilter->filter($folders, $keepInExportPaths) + ->willReturn($filteredFolders); + $this->exportIgnoreFilter->filter($files, $keepInExportPaths) + ->willReturn($filteredFiles); + + $this->existenceChecker->filterExisting('/project', $filteredFolders) + ->willReturn($filteredFolders); + $this->existenceChecker->filterExisting('/project', $filteredFiles) + ->willReturn($filteredFiles); + + $this->reader->read('/project/.gitattributes') + ->willReturn(''); + + $this->merger->merge('', $entries, $keepInExportPaths) + ->willReturn("/docs/ export-ignore\n/.editorconfig export-ignore"); + + $this->writer->write('/project/.gitattributes', "/docs/ export-ignore\n/.editorconfig export-ignore") + ->shouldBeCalledOnce(); + + self::assertSame(GitAttributesCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWithNoCandidatesWillSkipSynchronization(): void + { + $folders = ['/docs/']; + $files = ['/README.md']; + + $this->candidateProvider->folders() + ->willReturn($folders); + $this->candidateProvider->files() + ->willReturn($files); + $this->exportIgnoreFilter->filter($folders, []) + ->willReturn([]); + $this->exportIgnoreFilter->filter($files, []) + ->willReturn([]); + + $this->existenceChecker->filterExisting('/project', []) + ->willReturn([]); + + $this->reader->read('/project/.gitattributes') + ->shouldNotBeCalled(); + $this->merger->merge('', [], []) + ->shouldNotBeCalled(); + $this->writer->write('/project/.gitattributes', '') + ->shouldNotBeCalled(); + + $this->output->writeln('Synchronizing .gitattributes export-ignore rules...') + ->shouldBeCalled(); + $this->output->writeln( + 'No candidate paths found in repository. Skipping .gitattributes sync.' + ) + ->shouldBeCalled(); + + self::assertSame(GitAttributesCommand::SUCCESS, $this->invokeExecute()); + } +} diff --git a/tests/Command/SyncCommandTest.php b/tests/Command/SyncCommandTest.php index fdd215a..e429f50 100644 --- a/tests/Command/SyncCommandTest.php +++ b/tests/Command/SyncCommandTest.php @@ -22,6 +22,7 @@ use FastForward\DevTools\Command\SyncCommand; use FastForward\DevTools\GitAttributes\CandidateProvider; use FastForward\DevTools\GitAttributes\ExistenceChecker; +use FastForward\DevTools\GitAttributes\ExportIgnoreFilter; use FastForward\DevTools\GitAttributes\Merger as GitAttributesMerger; use FastForward\DevTools\GitIgnore\Classifier; use FastForward\DevTools\GitIgnore\GitIgnore; @@ -43,6 +44,7 @@ #[UsesClass(GitIgnoreCommand::class)] #[UsesClass(CandidateProvider::class)] #[UsesClass(ExistenceChecker::class)] +#[UsesClass(ExportIgnoreFilter::class)] #[UsesClass(GitAttributesMerger::class)] final class SyncCommandTest extends AbstractCommandTestCase { diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 009885d..81a7783 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -37,7 +37,10 @@ use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider; use FastForward\DevTools\GitAttributes\CandidateProvider; use FastForward\DevTools\GitAttributes\ExistenceChecker; +use FastForward\DevTools\GitAttributes\ExportIgnoreFilter; use FastForward\DevTools\GitAttributes\Merger as GitAttributesMerger; +use FastForward\DevTools\GitAttributes\Reader as GitAttributesReader; +use FastForward\DevTools\GitAttributes\Writer as GitAttributesWriter; use FastForward\DevTools\GitIgnore\Merger as GitIgnoreMerger; use FastForward\DevTools\GitIgnore\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -63,7 +66,10 @@ #[UsesClass(SkillsSynchronizer::class)] #[UsesClass(CandidateProvider::class)] #[UsesClass(ExistenceChecker::class)] +#[UsesClass(ExportIgnoreFilter::class)] #[UsesClass(GitAttributesMerger::class)] +#[UsesClass(GitAttributesReader::class)] +#[UsesClass(GitAttributesWriter::class)] #[UsesClass(GitIgnoreMerger::class)] #[UsesClass(Writer::class)] final class DevToolsCommandProviderTest extends TestCase diff --git a/tests/GitAttributes/ExportIgnoreFilterTest.php b/tests/GitAttributes/ExportIgnoreFilterTest.php new file mode 100644 index 0000000..7adbb14 --- /dev/null +++ b/tests/GitAttributes/ExportIgnoreFilterTest.php @@ -0,0 +1,57 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitAttributes; + +use FastForward\DevTools\GitAttributes\ExportIgnoreFilter; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(ExportIgnoreFilter::class)] +final class ExportIgnoreFilterTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function filterWillRemoveConfiguredPathsEvenWithSlashVariants(): void + { + $filter = new ExportIgnoreFilter(); + + $result = $filter->filter( + ['/.github/', '/tests/', '/README.md', '/phpunit.xml.dist'], + ['.github', '/tests', 'README.md/', ''], + ); + + self::assertSame(['/phpunit.xml.dist'], $result); + } + + /** + * @return void + */ + #[Test] + public function filterWillPreserveOriginalCandidateOrder(): void + { + $filter = new ExportIgnoreFilter(); + + $result = $filter->filter(['/docs/', '/.github/', '/README.md'], ['/README.md']); + + self::assertSame(['/docs/', '/.github/'], $result); + } +} diff --git a/tests/GitAttributes/MergerTest.php b/tests/GitAttributes/MergerTest.php new file mode 100644 index 0000000..896a6de --- /dev/null +++ b/tests/GitAttributes/MergerTest.php @@ -0,0 +1,157 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitAttributes; + +use FastForward\DevTools\GitAttributes\Merger; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Merger::class)] +final class MergerTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function mergeWillCreateManagedBlockWhenFileIsEmpty(): void + { + $merger = new Merger(); + + $result = $merger->merge('', ['/docs/', '/README.md']); + + self::assertSame("/docs/ export-ignore\n" . '/README.md export-ignore', $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillPreserveCustomEntries(): void + { + $merger = new Merger(); + $existingContent = implode("\n", ['*.zip -diff', '*.phar binary']); + + $result = $merger->merge($existingContent, ['/docs/']); + + self::assertSame("*.zip -diff\n*.phar binary\n" . '/docs/ export-ignore', $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillDeduplicateExistingAndGeneratedEntries(): void + { + $merger = new Merger(); + $existingContent = implode("\n", [ + '* text=auto', + '/.github/ export-ignore', + '/docs/ export-ignore', + '/tests/ export-ignore', + '/.gitattributes export-ignore', + '/.gitignore export-ignore', + '/.gitmodules export-ignore', + 'AGENTS.md export-ignore', + '/.github/ export-ignore', + '/.vscode/ export-ignore', + '/docs/ export-ignore', + '/tests/ export-ignore', + '/.editorconfig export-ignore', + '/.gitattributes export-ignore', + '/.gitignore export-ignore', + '/.gitmodules export-ignore', + '/README.md export-ignore', + ]); + + $result = $merger->merge($existingContent, [ + '/.github/', + '/.vscode/', + '/docs/', + '/tests/', + '/.editorconfig', + '/.gitattributes', + '/.gitignore', + '/.gitmodules', + '/README.md', + ]); + + self::assertSame( + "* text=auto\n" + . "/.github/ export-ignore\n" + . "/.vscode/ export-ignore\n" + . "/docs/ export-ignore\n" + . "/tests/ export-ignore\n" + . "/.editorconfig export-ignore\n" + . "/.gitattributes export-ignore\n" + . "/.gitignore export-ignore\n" + . "/.gitmodules export-ignore\n" + . "AGENTS.md export-ignore\n" + . '/README.md export-ignore', + $result, + ); + } + + /** + * @return void + */ + #[Test] + public function mergeWillRemoveExistingExportIgnoreRulesForKeptPaths(): void + { + $merger = new Merger(); + $existingContent = implode("\n", [ + '* text=auto', + '/.gitignore export-ignore', + 'AGENTS.md export-ignore', + '/README.md export-ignore', + ]); + + $result = $merger->merge($existingContent, ['/README.md'], ['/.gitignore', '/AGENTS.md']); + + self::assertSame("* text=auto\n" . '/README.md export-ignore', $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillSortExportIgnoreEntriesWithDirectoriesBeforeFiles(): void + { + $merger = new Merger(); + $existingContent = implode("\n", [ + '* text=auto', + '/README.md export-ignore', + '/docs/ export-ignore', + '/AGENTS.md export-ignore', + ]); + + $result = $merger->merge($existingContent, ['/.vscode/', '/tests/', '/.gitattributes']); + + self::assertSame( + "* text=auto\n" + . "/.vscode/ export-ignore\n" + . "/docs/ export-ignore\n" + . "/tests/ export-ignore\n" + . "/.gitattributes export-ignore\n" + . "/AGENTS.md export-ignore\n" + . '/README.md export-ignore', + $result, + ); + } +} diff --git a/tests/GitAttributes/ReaderTest.php b/tests/GitAttributes/ReaderTest.php new file mode 100644 index 0000000..7a05c38 --- /dev/null +++ b/tests/GitAttributes/ReaderTest.php @@ -0,0 +1,60 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitAttributes; + +use FastForward\DevTools\GitAttributes\Reader; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +use function Safe\file_put_contents; +use function Safe\unlink; + +#[CoversClass(Reader::class)] +final class ReaderTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function readWithNonExistentFileWillReturnEmptyString(): void + { + $reader = new Reader(); + + self::assertSame('', $reader->read('/non/existent/.gitattributes')); + } + + /** + * @return void + */ + #[Test] + public function readWithExistingFileWillReturnFileContents(): void + { + $reader = new Reader(); + $tempFile = sys_get_temp_dir() . '/test_gitattributes_reader_' . uniqid() . '.gitattributes'; + + file_put_contents($tempFile, "*.zip -diff\n"); + + try { + self::assertSame("*.zip -diff\n", $reader->read($tempFile)); + } finally { + @unlink($tempFile); + } + } +} diff --git a/tests/GitAttributes/WriterTest.php b/tests/GitAttributes/WriterTest.php new file mode 100644 index 0000000..b0500ad --- /dev/null +++ b/tests/GitAttributes/WriterTest.php @@ -0,0 +1,92 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitAttributes; + +use FastForward\DevTools\GitAttributes\Writer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Filesystem\Filesystem; + +#[CoversClass(Writer::class)] +final class WriterTest extends TestCase +{ + use ProphecyTrait; + + /** + * @return void + */ + #[Test] + public function writeWillAppendTrailingLineFeed(): void + { + $filesystem = $this->prophesize(Filesystem::class); + $writer = new Writer($filesystem->reveal()); + + $writer->write('/project/.gitattributes', '*.zip -diff'); + + $filesystem->dumpFile('/project/.gitattributes', "*.zip -diff\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillAlignAttributeColumnsUsingTheLongestPathSpec(): void + { + $filesystem = $this->prophesize(Filesystem::class); + $writer = new Writer($filesystem->reveal()); + + $writer->write( + '/project/.gitattributes', + implode("\n", ['* text=auto', '/.github/ export-ignore', '/.gitattributes export-ignore']), + ); + + $filesystem->dumpFile( + '/project/.gitattributes', + "* text=auto\n" + . "/.github/ export-ignore\n" + . "/.gitattributes export-ignore\n", + ) + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillRespectEscapedWhitespaceInsideThePathSpec(): void + { + $filesystem = $this->prophesize(Filesystem::class); + $writer = new Writer($filesystem->reveal()); + + $writer->write( + '/project/.gitattributes', + implode("\n", ['docs\ with\ spaces export-ignore', '/.github/ export-ignore']), + ); + + $filesystem->dumpFile( + '/project/.gitattributes', + "docs\\ with\\ spaces export-ignore\n" + . "/.github/ export-ignore\n", + ) + ->shouldBeCalledOnce(); + } +} From 6f5e6ed1e653b7212cedef9fd5704f1b92ffc892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Fri, 10 Apr 2026 12:26:27 -0300 Subject: [PATCH 4/6] feat(gitattributes): expand candidate list and update docs - Add full list of folders/files from issue #13 to CandidateProvider - Add gitattributes command documentation to specialized-commands.rst - Update README.md with gitattributes command and table entry - Update SyncCommand docs to mention gitattributes call --- README.md | 6 ++- docs/running/specialized-commands.rst | 22 ++++++++ src/GitAttributes/CandidateProvider.php | 67 +++++++++++++++++++++---- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 300fc69..ce43b3b 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ composer dev-tools skills # Merges and synchronizes .gitignore files composer dev-tools gitignore +# Manages .gitattributes export-ignore rules for leaner package archives +composer dev-tools gitattributes + # Generates a LICENSE file from composer.json license information composer dev-tools license @@ -105,7 +108,8 @@ automation assets. | `composer dev-tools dependencies` | Reports missing and unused Composer dependencies. | | `composer dev-tools docs` | Builds the HTML documentation site from PSR-4 code and `docs/`. | | `composer dev-tools skills` | Creates or repairs packaged skill links in `.agents/skills`. | -| `composer dev-tools:sync` | Updates scripts, workflow stubs, `.editorconfig`, `.gitignore`, wiki setup, and packaged skills. | +| `composer dev-tools gitattributes` | Manages export-ignore rules in .gitattributes. | +| `composer dev-tools:sync` | Updates scripts, workflow stubs, `.editorconfig`, `.gitignore`, `.gitattributes`, wiki setup, and packaged skills. | ## 🔌 Integration diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index 0ba25d7..15bf54d 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -177,9 +177,31 @@ Important details: missing. - it calls ``gitignore`` to merge the canonical .gitignore with the project's .gitignore; +- it calls ``gitattributes`` to manage export-ignore rules in .gitattributes; - it calls ``skills`` so ``.agents/skills`` contains links to the packaged skill set. +``gitattributes`` +---------------- + +Manages .gitattributes export-ignore rules for leaner Composer package archives. + +.. code-block:: bash + + composer dev-tools gitattributes + +Important details: + +- it adds export-ignore entries for repository-only files and directories; +- it only adds entries for paths that actually exist in the repository; +- it respects the ``extra.gitattributes.keep-in-export`` configuration to + keep specific paths in exported archives; +- it preserves existing custom .gitattributes rules; +- it deduplicates equivalent entries and sorts them with directories before + files, then alphabetically; +- it uses CandidateProvider, ExistenceChecker, ExportIgnoreFilter, Merger, + Reader, and Writer components from the GitAttributes namespace. + ``gitignore`` ------------- diff --git a/src/GitAttributes/CandidateProvider.php b/src/GitAttributes/CandidateProvider.php index facae9f..ab2c374 100644 --- a/src/GitAttributes/CandidateProvider.php +++ b/src/GitAttributes/CandidateProvider.php @@ -33,17 +33,25 @@ final class CandidateProvider implements CandidateProviderInterface public function folders(): array { return [ + '/.changeset/', + '/.circleci/', '/.devcontainer/', '/.github/', + '/.gitlab/', '/.idea/', + '/.php-cs-fixer.cache/', '/.vscode/', '/benchmarks/', '/build/', '/coverage/', + '/docker/', '/docs/', '/examples/', '/fixtures/', + '/migrations/', '/scripts/', + '/src-dev/', + '/stubs/', '/tests/', '/tools/', ]; @@ -55,25 +63,66 @@ public function folders(): array public function files(): array { return [ + '/.dockerignore', '/.editorconfig', + '/.env', + '/.env.dist', + '/.env.example', '/.gitattributes', '/.gitignore', '/.gitmodules', + '/.gitlab-ci.yml', + '/.php-cs-fixer.dist.php', + '/.php-cs-fixer.php', + '/.phpunit.result.cache', + '/.styleci.yml', + '/.travis.yml', + '/AGENTS.md', '/CODE_OF_CONDUCT.md', '/CONTRIBUTING.md', + '/Dockerfile', + '/GEMINI.md', + '/Governance.md', '/Makefile', - '/phpunit.xml.dist', '/README.md', - '/AGENTS.md', - '/GEMINI.md', - '/Dockerfile', - '/.dockerignore', - '/.env', - '/docker-compose.yml', + '/SECURITY.md', + '/SUPPORT.md', + '/UPGRADE.md', + '/UPGRADING.md', + '/Vagrantfile', + '/bitbucket-pipelines.yml', + '/codecov.yml', + '/composer-normalize.json', + '/composer-require-checker.json', '/docker-compose.override.yml', + '/docker-compose.yaml', + '/docker-compose.yml', + '/docker-bake.hcl', '/docker-stack.yml', - '/compose.yml', - '/compose.override.yml', + '/docker-stack.yaml', + '/ecs.php', + '/grumphp.yml', + '/grumphp.yml.dist', + '/infection.json', + '/infection.json.dist', + '/makefile', + '/phpbench.json', + '/phpbench.json.dist', + '/phpcs.xml', + '/phpcs.xml.dist', + '/phpmd.xml', + '/phpmd.xml.dist', + '/phpstan-baseline.neon', + '/phpstan-bootstrap.php', + '/phpstan.neon', + '/phpstan.neon.dist', + '/phpunit.xml.dist', + '/psalm-baseline.xml', + '/psalm.xml', + '/psalm.xml.dist', + '/rector.php', + '/renovate.json', + '/renovate.json5', ]; } From 93c8a545f22a44807d0dcd624254e069d3d7e89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Fri, 10 Apr 2026 23:20:54 -0300 Subject: [PATCH 5/6] feat(tests): add comprehensive tests for GitAttributes and License components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- src/GitAttributes/CandidateProvider.php | 1 + src/License/Generator.php | 16 +- .../Runner/Extension/DevToolsExtension.php | 2 + tests/GitAttributes/CandidateProviderTest.php | 151 ++++++++++ tests/GitAttributes/ExistenceCheckerTest.php | 163 +++++++++++ tests/GitAttributes/MergerTest.php | 141 +++++++++- tests/GitAttributes/WriterTest.php | 137 ++++++++- tests/License/GeneratorTest.php | 260 ++++++++++++++++++ 8 files changed, 837 insertions(+), 34 deletions(-) create mode 100644 tests/GitAttributes/CandidateProviderTest.php create mode 100644 tests/GitAttributes/ExistenceCheckerTest.php create mode 100644 tests/License/GeneratorTest.php diff --git a/src/GitAttributes/CandidateProvider.php b/src/GitAttributes/CandidateProvider.php index ab2c374..9f6c0fd 100644 --- a/src/GitAttributes/CandidateProvider.php +++ b/src/GitAttributes/CandidateProvider.php @@ -94,6 +94,7 @@ public function files(): array '/codecov.yml', '/composer-normalize.json', '/composer-require-checker.json', + '/context7.json', '/docker-compose.override.yml', '/docker-compose.yaml', '/docker-compose.yml', diff --git a/src/License/Generator.php b/src/License/Generator.php index f35c182..5ef8d7f 100644 --- a/src/License/Generator.php +++ b/src/License/Generator.php @@ -38,17 +38,17 @@ /** * Creates a new Generator instance. * - * @param Reader $reader The reader for extracting metadata from composer.json - * @param Resolver $resolver The resolver for mapping license identifiers to templates - * @param TemplateLoader $templateLoader The loader for reading template files - * @param PlaceholderResolver $placeholderResolver The resolver for template placeholders + * @param ReaderInterface $reader The reader for extracting metadata from composer.json + * @param ResolverInterface $resolver The resolver for mapping license identifiers to templates + * @param TemplateLoaderInterface $templateLoader The loader for reading template files + * @param PlaceholderResolverInterface $placeholderResolver The resolver for template placeholders * @param Filesystem $filesystem The filesystem component for file operations */ public function __construct( - private Reader $reader, - private Resolver $resolver, - private TemplateLoader $templateLoader, - private PlaceholderResolver $placeholderResolver, + private ReaderInterface $reader, + private ResolverInterface $resolver, + private TemplateLoaderInterface $templateLoader, + private PlaceholderResolverInterface $placeholderResolver, private Filesystem $filesystem = new Filesystem() ) {} diff --git a/src/PhpUnit/Runner/Extension/DevToolsExtension.php b/src/PhpUnit/Runner/Extension/DevToolsExtension.php index 58bd21c..895d72c 100644 --- a/src/PhpUnit/Runner/Extension/DevToolsExtension.php +++ b/src/PhpUnit/Runner/Extension/DevToolsExtension.php @@ -83,6 +83,8 @@ public function __construct( * parameters passed by PHPUnit * * @return void + * + * @codeCoverageIgnore */ public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void { diff --git a/tests/GitAttributes/CandidateProviderTest.php b/tests/GitAttributes/CandidateProviderTest.php new file mode 100644 index 0000000..84f840a --- /dev/null +++ b/tests/GitAttributes/CandidateProviderTest.php @@ -0,0 +1,151 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitAttributes; + +use FastForward\DevTools\GitAttributes\CandidateProvider; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(CandidateProvider::class)] +final class CandidateProviderTest extends TestCase +{ + private readonly CandidateProvider $provider; + + /** + * @return void + */ + protected function setUp(): void + { + $this->provider = new CandidateProvider(); + } + + /** + * @return void + */ + #[Test] + public function foldersWillReturnNonEmptyArray(): void + { + self::assertNotEmpty($this->provider->folders()); + } + + /** + * @return void + */ + #[Test] + public function foldersWillStartWithSlash(): void + { + self::assertStringStartsWith('/', $this->provider->folders()[0]); + } + + /** + * @return void + */ + #[Test] + public function foldersWillEndWithSlash(): void + { + $folders = $this->provider->folders(); + + self::assertStringEndsWith('/', $folders[0]); + } + + /** + * @return void + */ + #[Test] + public function filesWillReturnNonEmptyArray(): void + { + self::assertNotEmpty($this->provider->files()); + } + + /** + * @return void + */ + #[Test] + public function filesWillStartWithSlash(): void + { + self::assertStringStartsWith('/', $this->provider->files()[0]); + } + + /** + * @return void + */ + #[Test] + public function filesWillNotEndWithSlash(): void + { + $files = $this->provider->files(); + + self::assertStringEndsNotWith('/', $files[0]); + } + + /** + * @return void + */ + #[Test] + public function allWillCombineFoldersAndFiles(): void + { + self::assertCount( + \count($this->provider->folders()) + \count($this->provider->files()), + $this->provider->all(), + ); + } + + /** + * @return void + */ + #[Test] + public function allWillHaveFoldersFirst(): void + { + $all = $this->provider->all(); + $foldersCount = \count($this->provider->folders()); + + self::assertSame($this->provider->folders(), \array_slice($all, 0, $foldersCount)); + } + + /** + * @return void + */ + #[Test] + public function allWillHaveFilesAfterFolders(): void + { + $all = $this->provider->all(); + + $foldersCount = \count($this->provider->folders()); + + self::assertSame($this->provider->files(), \array_slice($all, $foldersCount)); + } + + /** + * @return void + */ + #[Test] + public function folderWillContainDotGithub(): void + { + self::assertContains('/.github/', $this->provider->folders()); + } + + /** + * @return void + */ + #[Test] + public function filesWillContainGitignore(): void + { + self::assertContains('/.gitignore', $this->provider->files()); + } +} diff --git a/tests/GitAttributes/ExistenceCheckerTest.php b/tests/GitAttributes/ExistenceCheckerTest.php new file mode 100644 index 0000000..f07bb68 --- /dev/null +++ b/tests/GitAttributes/ExistenceCheckerTest.php @@ -0,0 +1,163 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitAttributes; + +use FastForward\DevTools\GitAttributes\ExistenceChecker; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Filesystem\Filesystem; + +#[CoversClass(ExistenceChecker::class)] +final class ExistenceCheckerTest extends TestCase +{ + use ProphecyTrait; + + /** + * @property ObjectProphecy $filesystem + */ + private readonly ObjectProphecy $filesystem; + + private readonly ExistenceChecker $checker; + + /** + * @return void + */ + protected function setUp(): void + { + $this->filesystem = $this->prophesize(Filesystem::class); + $this->checker = new ExistenceChecker($this->filesystem->reveal()); + } + + /** + * @return void + */ + #[Test] + public function existsWillReturnTrueWhenPathExists(): void + { + $this->filesystem->exists('/project/.github/') + ->willReturn(true) + ->shouldBeCalledOnce(); + + self::assertTrue($this->checker->exists('/project', '/.github/')); + } + + /** + * @return void + */ + #[Test] + public function existsWillReturnFalseWhenPathDoesNotExist(): void + { + $this->filesystem->exists('/project/.nonexistent/') + ->willReturn(false) + ->shouldBeCalledOnce(); + + self::assertFalse($this->checker->exists('/project', '/.nonexistent/')); + } + + /** + * @return void + */ + #[Test] + public function filterExistingWillKeepOnlyExistingPaths(): void + { + $this->filesystem->exists('/project/.github/') + ->willReturn(true); + $this->filesystem->exists('/project/README.md') + ->willReturn(false); + $this->filesystem->exists('/project/docs/') + ->willReturn(true); + + $result = $this->checker->filterExisting('/project', ['/.github/', '/README.md', '/docs/']); + + self::assertSame(['/.github/', '/docs/'], $result); + } + + /** + * @return void + */ + #[Test] + public function filterExistingWillReturnEmptyArrayWhenNoneExist(): void + { + $this->filesystem->exists('/project/fake1') + ->willReturn(false); + $this->filesystem->exists('/project/fake2') + ->willReturn(false); + + $result = $this->checker->filterExisting('/project', ['/fake1', '/fake2']); + + self::assertSame([], $result); + } + + /** + * @return void + */ + #[Test] + public function isDirectoryWillReturnTrueForDirectory(): void + { + self::assertTrue($this->checker->isDirectory(__DIR__, '')); + } + + /** + * @return void + */ + #[Test] + public function isDirectoryWillReturnFalseForFile(): void + { + self::assertFalse($this->checker->isDirectory(__DIR__, '/ExistenceCheckerTest.php')); + } + + /** + * @return void + */ + #[Test] + public function isFileWillReturnTrueForFile(): void + { + self::assertTrue($this->checker->isFile(__DIR__, '/ExistenceCheckerTest.php')); + } + + /** + * @return void + */ + #[Test] + public function isFileWillReturnFalseForDirectory(): void + { + self::assertFalse($this->checker->isFile(__DIR__, '')); + } + + /** + * @return void + */ + #[Test] + public function isDirectoryWillReturnFalseForNonExistent(): void + { + self::assertFalse($this->checker->isDirectory('/project', '/nonexistent')); + } + + /** + * @return void + */ + #[Test] + public function isFileWillReturnFalseForNonExistent(): void + { + self::assertFalse($this->checker->isFile('/project', '/nonexistent.php')); + } +} diff --git a/tests/GitAttributes/MergerTest.php b/tests/GitAttributes/MergerTest.php index 896a6de..6847de8 100644 --- a/tests/GitAttributes/MergerTest.php +++ b/tests/GitAttributes/MergerTest.php @@ -26,15 +26,23 @@ #[CoversClass(Merger::class)] final class MergerTest extends TestCase { + private Merger $merger; + + /** + * @return void + */ + protected function setUp(): void + { + $this->merger = new Merger(); + } + /** * @return void */ #[Test] public function mergeWillCreateManagedBlockWhenFileIsEmpty(): void { - $merger = new Merger(); - - $result = $merger->merge('', ['/docs/', '/README.md']); + $result = $this->merger->merge('', ['/docs/', '/README.md']); self::assertSame("/docs/ export-ignore\n" . '/README.md export-ignore', $result); } @@ -45,10 +53,9 @@ public function mergeWillCreateManagedBlockWhenFileIsEmpty(): void #[Test] public function mergeWillPreserveCustomEntries(): void { - $merger = new Merger(); $existingContent = implode("\n", ['*.zip -diff', '*.phar binary']); - $result = $merger->merge($existingContent, ['/docs/']); + $result = $this->merger->merge($existingContent, ['/docs/']); self::assertSame("*.zip -diff\n*.phar binary\n" . '/docs/ export-ignore', $result); } @@ -59,7 +66,6 @@ public function mergeWillPreserveCustomEntries(): void #[Test] public function mergeWillDeduplicateExistingAndGeneratedEntries(): void { - $merger = new Merger(); $existingContent = implode("\n", [ '* text=auto', '/.github/ export-ignore', @@ -80,7 +86,7 @@ public function mergeWillDeduplicateExistingAndGeneratedEntries(): void '/README.md export-ignore', ]); - $result = $merger->merge($existingContent, [ + $result = $this->merger->merge($existingContent, [ '/.github/', '/.vscode/', '/docs/', @@ -114,7 +120,6 @@ public function mergeWillDeduplicateExistingAndGeneratedEntries(): void #[Test] public function mergeWillRemoveExistingExportIgnoreRulesForKeptPaths(): void { - $merger = new Merger(); $existingContent = implode("\n", [ '* text=auto', '/.gitignore export-ignore', @@ -122,7 +127,7 @@ public function mergeWillRemoveExistingExportIgnoreRulesForKeptPaths(): void '/README.md export-ignore', ]); - $result = $merger->merge($existingContent, ['/README.md'], ['/.gitignore', '/AGENTS.md']); + $result = $this->merger->merge($existingContent, ['/README.md'], ['/.gitignore', '/AGENTS.md']); self::assertSame("* text=auto\n" . '/README.md export-ignore', $result); } @@ -133,7 +138,6 @@ public function mergeWillRemoveExistingExportIgnoreRulesForKeptPaths(): void #[Test] public function mergeWillSortExportIgnoreEntriesWithDirectoriesBeforeFiles(): void { - $merger = new Merger(); $existingContent = implode("\n", [ '* text=auto', '/README.md export-ignore', @@ -141,7 +145,7 @@ public function mergeWillSortExportIgnoreEntriesWithDirectoriesBeforeFiles(): vo '/AGENTS.md export-ignore', ]); - $result = $merger->merge($existingContent, ['/.vscode/', '/tests/', '/.gitattributes']); + $result = $this->merger->merge($existingContent, ['/.vscode/', '/tests/', '/.gitattributes']); self::assertSame( "* text=auto\n" @@ -154,4 +158,119 @@ public function mergeWillSortExportIgnoreEntriesWithDirectoriesBeforeFiles(): vo $result, ); } + + /** + * @return void + */ + #[Test] + public function mergeWillPreserveComments(): void + { + $existingContent = "# This is a comment\n*.zip -diff"; + + $result = $this->merger->merge($existingContent, ['/docs/']); + + self::assertStringStartsWith('#', $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillNormalizeWhitespaceInExistingEntries(): void + { + $existingContent = '/docs/ export-ignore'; + + $result = $this->merger->merge($existingContent, ['/tests/']); + + self::assertStringContainsString('/docs/ export-ignore', $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillDeduplicateNormalizedEntries(): void + { + $existingContent = '/.github/ export-ignore'; + + $result = $this->merger->merge($existingContent, ['/.github/']); + + self::assertCount(1, explode("\n", trim($result))); + } + + /** + * @return void + */ + #[Test] + public function mergeWithEmptyExistingContentWillCreateCleanOutput(): void + { + $result = $this->merger->merge('', ['/docs/', '/tests/']); + + self::assertSame("/docs/ export-ignore\n/tests/ export-ignore", $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillHandleGlobPatternsWithExportIgnore(): void + { + $existingContent = '*.pdf export-ignore'; + + $result = $this->merger->merge($existingContent, ['/docs/']); + + self::assertStringContainsString('*.pdf export-ignore', $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillSortDirectoriesBeforeFiles(): void + { + $result = $this->merger->merge('', ['/README.md', '/docs/', '/tests/', '/.editorconfig']); + + $lines = explode("\n", $result); + self::assertSame('/docs/ export-ignore', $lines[0]); + self::assertSame('/tests/ export-ignore', $lines[1]); + } + + /** + * @return void + */ + #[Test] + public function mergeWillHandleMultipleWhitespaceNormalize(): void + { + $existingContent = "/docs/ export-ignore\n/tests/ export-ignore"; + + $result = $this->merger->merge($existingContent, []); + + self::assertStringContainsString('/docs/ export-ignore', $result); + } + + /** + * @return void + */ + #[Test] + public function mergeWillRemoveDuplicateNonExportIgnoreLines(): void + { + $existingContent = "* text=auto\n* text=auto"; + + $result = $this->merger->merge($existingContent, []); + + self::assertSame(1, substr_count($result, '* text=auto')); + } + + /** + * @return void + */ + #[Test] + public function mergeWillPreserveLeadingWhitespaceInCustomEntries(): void + { + $existingContent = ' * text=auto'; + + $result = $this->merger->merge($existingContent, []); + + self::assertStringContainsString('* text=auto', $result); + } } diff --git a/tests/GitAttributes/WriterTest.php b/tests/GitAttributes/WriterTest.php index b0500ad..b0b7eb7 100644 --- a/tests/GitAttributes/WriterTest.php +++ b/tests/GitAttributes/WriterTest.php @@ -22,6 +22,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Prophecy\Prophecy\ObjectProphecy; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Filesystem\Filesystem; @@ -30,18 +31,31 @@ final class WriterTest extends TestCase { use ProphecyTrait; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $filesystem; + + private Writer $writer; + + /** + * @return void + */ + protected function setUp(): void + { + $this->filesystem = $this->prophesize(Filesystem::class); + $this->writer = new Writer($this->filesystem->reveal()); + } + /** * @return void */ #[Test] public function writeWillAppendTrailingLineFeed(): void { - $filesystem = $this->prophesize(Filesystem::class); - $writer = new Writer($filesystem->reveal()); - - $writer->write('/project/.gitattributes', '*.zip -diff'); + $this->writer->write('/project/.gitattributes', '*.zip -diff'); - $filesystem->dumpFile('/project/.gitattributes', "*.zip -diff\n") + $this->filesystem->dumpFile('/project/.gitattributes', "*.zip -diff\n") ->shouldBeCalledOnce(); } @@ -51,15 +65,12 @@ public function writeWillAppendTrailingLineFeed(): void #[Test] public function writeWillAlignAttributeColumnsUsingTheLongestPathSpec(): void { - $filesystem = $this->prophesize(Filesystem::class); - $writer = new Writer($filesystem->reveal()); - - $writer->write( + $this->writer->write( '/project/.gitattributes', implode("\n", ['* text=auto', '/.github/ export-ignore', '/.gitattributes export-ignore']), ); - $filesystem->dumpFile( + $this->filesystem->dumpFile( '/project/.gitattributes', "* text=auto\n" . "/.github/ export-ignore\n" @@ -74,19 +85,115 @@ public function writeWillAlignAttributeColumnsUsingTheLongestPathSpec(): void #[Test] public function writeWillRespectEscapedWhitespaceInsideThePathSpec(): void { - $filesystem = $this->prophesize(Filesystem::class); - $writer = new Writer($filesystem->reveal()); - - $writer->write( + $this->writer->write( '/project/.gitattributes', implode("\n", ['docs\ with\ spaces export-ignore', '/.github/ export-ignore']), ); - $filesystem->dumpFile( + $this->filesystem->dumpFile( '/project/.gitattributes', "docs\\ with\\ spaces export-ignore\n" . "/.github/ export-ignore\n", ) ->shouldBeCalledOnce(); } + + /** + * @return void + */ + #[Test] + public function writeWillPreserveEmptyLines(): void + { + $this->writer->write('/project/.gitattributes', "/docs/ export-ignore\n\n/tests/ export-ignore"); + + $this->filesystem->dumpFile('/project/.gitattributes', "/docs/ export-ignore\n\n/tests/ export-ignore\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillNormalizeMultipleSpaces(): void + { + $this->writer->write('/project/.gitattributes', '/docs/ export-ignore'); + + $this->filesystem->dumpFile('/project/.gitattributes', "/docs/ export-ignore\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillPreserveComments(): void + { + $this->writer->write('/project/.gitattributes', "# Managed by dev-tools\n/docs/ export-ignore"); + + $this->filesystem->dumpFile('/project/.gitattributes', "# Managed by dev-tools\n/docs/ export-ignore\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillHandleMultipleSpacesBetweenPathAndAttribute(): void + { + $this->writer->write('/project/.gitattributes', '/docs/ export-ignore'); + + $this->filesystem->dumpFile('/project/.gitattributes', "/docs/ export-ignore\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillNormalizeContentWithExtraWhitespaceCharacters(): void + { + $this->writer->write('/project/.gitattributes', ' /docs/ export-ignore '); + + $this->filesystem->dumpFile('/project/.gitattributes', "/docs/ export-ignore\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillHandlePathsWithoutAttributesAsRawEntry(): void + { + $this->writer->write('/project/.gitattributes', '/docs/'); + + $this->filesystem->dumpFile('/project/.gitattributes', "/docs/\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillHandlePathsWithOnlyWhitespaceAttributeAsRawEntry(): void + { + $this->writer->write('/project/.gitattributes', '/docs/ '); + + $this->filesystem->dumpFile('/project/.gitattributes', "/docs/\n") + ->shouldBeCalledOnce(); + } + + /** + * @return void + */ + #[Test] + public function writeWillAlignWithTabSeparatedEntries(): void + { + $this->writer->write('/project/.gitattributes', "/short\tbinary\n/longer/path/to/file\tbinary"); + + $this->filesystem->dumpFile( + '/project/.gitattributes', + "/short binary\n/longer/path/to/file binary\n", + ) + ->shouldBeCalledOnce(); + } } diff --git a/tests/License/GeneratorTest.php b/tests/License/GeneratorTest.php new file mode 100644 index 0000000..2af5a2d --- /dev/null +++ b/tests/License/GeneratorTest.php @@ -0,0 +1,260 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\License; + +use FastForward\DevTools\License\Generator; +use FastForward\DevTools\License\PlaceholderResolverInterface; +use FastForward\DevTools\License\ReaderInterface; +use FastForward\DevTools\License\ResolverInterface; +use FastForward\DevTools\License\TemplateLoaderInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\Prophecy\ObjectProphecy; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Filesystem\Filesystem; + +#[CoversClass(Generator::class)] +final class GeneratorTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $reader; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $resolver; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $templateLoader; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $placeholderResolver; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $filesystem; + + private Generator $generator; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->reader = $this->prophesize(ReaderInterface::class); + $this->resolver = $this->prophesize(ResolverInterface::class); + $this->templateLoader = $this->prophesize(TemplateLoaderInterface::class); + $this->placeholderResolver = $this->prophesize(PlaceholderResolverInterface::class); + $this->filesystem = $this->prophesize(Filesystem::class); + + $this->generator = new Generator( + $this->reader->reveal(), + $this->resolver->reveal(), + $this->templateLoader->reveal(), + $this->placeholderResolver->reveal(), + $this->filesystem->reveal(), + ); + } + + /** + * @return void + */ + #[Test] + public function generateWithMissingLicenseWillReturnNull(): void + { + $this->reader->getLicense() + ->willReturn(null); + + $result = $this->generator->generate('/tmp/LICENSE'); + + self::assertNull($result); + } + + /** + * @return void + */ + #[Test] + public function generateWithUnsupportedLicenseWillReturnNull(): void + { + $this->reader->getLicense() + ->willReturn('GPL-3.0-only'); + $this->resolver->isSupported('GPL-3.0-only') + ->willReturn(false); + + $result = $this->generator->generate('/tmp/LICENSE'); + + self::assertNull($result); + } + + /** + * @return void + */ + #[Test] + public function generateWillSkipWhenLicenseFileExists(): void + { + $this->reader->getLicense() + ->willReturn('MIT'); + $this->resolver->isSupported('MIT') + ->willReturn(true); + $this->filesystem->exists('/tmp/LICENSE') + ->willReturn(true); + + $result = $this->generator->generate('/tmp/LICENSE'); + + self::assertNull($result); + } + + /** + * @return void + */ + #[Test] + public function generateWithValidLicenseWillCreateFile(): void + { + $targetPath = '/tmp/LICENSE'; + + $this->reader->getLicense() + ->willReturn('MIT'); + $this->reader->getAuthors() + ->willReturn([ + [ + 'name' => 'Test Author', + 'email' => 'test@example.com', + ], + ]); + $this->reader->getYear() + ->willReturn(2026); + $this->reader->getVendor() + ->willReturn('fast-forward'); + $this->reader->getPackageName() + ->willReturn('fast-forward/dev-tools'); + $this->resolver->isSupported('MIT') + ->willReturn(true); + $this->resolver->resolve('MIT') + ->willReturn('MIT.txt'); + $this->templateLoader->load('MIT.txt') + ->willReturn('Copyright {{year}} {{author}}'); + $this->placeholderResolver->resolve(Argument::type('string'), Argument::type('array'))->willReturn( + 'Copyright 2026 Test Author' + ); + $this->filesystem->exists($targetPath) + ->willReturn(false); + $this->filesystem->dumpFile($targetPath, Argument::type('string'))->shouldBeCalled(); + + $result = $this->generator->generate($targetPath); + + self::assertNotNull($result); + self::assertStringContainsString('Copyright 2026 Test Author', $result); + } + + /** + * @return void + */ + #[Test] + public function generateWillReplacePlaceholders(): void + { + $targetPath = uniqid('LICENSE_'); + + $this->reader->getLicense() + ->willReturn('MIT'); + $this->reader->getAuthors() + ->willReturn([ + [ + 'name' => 'Test Author', + 'email' => 'test@example.com', + ], + ]); + $this->reader->getYear() + ->willReturn(2026); + $this->reader->getVendor() + ->willReturn('fast-forward'); + $this->reader->getPackageName() + ->willReturn('fast-forward/dev-tools'); + $this->resolver->isSupported('MIT') + ->willReturn(true); + $this->resolver->resolve('MIT') + ->willReturn('MIT.txt'); + $this->templateLoader->load('MIT.txt') + ->willReturn('Copyright {{year}} {{author}}'); + $this->placeholderResolver->resolve(Argument::type('string'), Argument::type('array'))->willReturn( + 'Copyright 2026 Test Author fast-forward' + ); + $this->filesystem->exists($targetPath) + ->willReturn(false); + $this->filesystem->dumpFile($targetPath, Argument::type('string'))->shouldBeCalled(); + + $result = $this->generator->generate($targetPath); + + self::assertNotNull($result); + self::assertStringContainsString('Test Author', $result); + self::assertStringContainsString('fast-forward', $result); + } + + /** + * @return void + */ + #[Test] + public function hasLicenseWithValidLicenseWillReturnTrue(): void + { + $this->reader->getLicense() + ->willReturn('MIT'); + $this->resolver->isSupported('MIT') + ->willReturn(true); + + self::assertTrue($this->generator->hasLicense()); + } + + /** + * @return void + */ + #[Test] + public function hasLicenseWithNoLicenseWillReturnFalse(): void + { + $this->reader->getLicense() + ->willReturn(null); + + self::assertFalse($this->generator->hasLicense()); + } + + /** + * @return void + */ + #[Test] + public function hasLicenseWithUnsupportedLicenseWillReturnFalse(): void + { + $this->reader->getLicense() + ->willReturn('GPL-3.0-only'); + $this->resolver->isSupported('GPL-3.0-only') + ->willReturn(false); + + self::assertFalse($this->generator->hasLicense()); + } +} From 11f5186d18afd6394aa15d7a999cb362e5d871a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Fri, 10 Apr 2026 23:23:26 -0300 Subject: [PATCH 6/6] feat(gitattributes): add context7.json to export-ignore list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 39539fd..114f21a 100755 --- a/.gitattributes +++ b/.gitattributes @@ -6,4 +6,5 @@ /.gitattributes export-ignore /.gitmodules export-ignore /AGENTS.md export-ignore +/context7.json export-ignore /README.md export-ignore