diff --git a/.gitattributes b/.gitattributes
index b4665b0..114f21a 100755
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,8 +1,10 @@
-* text=auto
-/.github/ export-ignore
-/docs/ export-ignore
-/tests/ export-ignore
-/.gitattributes export-ignore
-/.gitignore export-ignore
-/.gitmodules export-ignore
-AGENTS.md export-ignore
+* text=auto
+/.github/ export-ignore
+/.vscode/ export-ignore
+/docs/ export-ignore
+/tests/ export-ignore
+/.gitattributes export-ignore
+/.gitmodules export-ignore
+/AGENTS.md export-ignore
+/context7.json export-ignore
+/README.md export-ignore
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/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/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/Command/GitAttributesCommand.php b/src/Command/GitAttributesCommand.php
new file mode 100644
index 0000000..5ed12fc
--- /dev/null
+++ b/src/Command/GitAttributesCommand.php
@@ -0,0 +1,185 @@
+
+ * @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\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;
+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
+{
+ 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 $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 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.'
+ );
+ }
+
+ /**
+ * 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();
+ $keepInExportPaths = $this->configuredKeepInExportPaths();
+
+ $folderCandidates = $this->exportIgnoreFilter->filter($this->candidateProvider->folders(), $keepInExportPaths);
+ $fileCandidates = $this->exportIgnoreFilter->filter($this->candidateProvider->files(), $keepInExportPaths);
+
+ $existingFolders = $this->existenceChecker->filterExisting($basePath, $folderCandidates);
+ $existingFiles = $this->existenceChecker->filterExisting($basePath, $fileCandidates);
+
+ $entries = [...$existingFolders, ...$existingFiles];
+
+ if ([] === $entries) {
+ $output->writeln(
+ 'No candidate paths found in repository. Skipping .gitattributes sync.'
+ );
+
+ return self::SUCCESS;
+ }
+
+ $gitattributesPath = Path::join($basePath, '.gitattributes');
+ $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.',
+ \count($entries)
+ ));
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Resolves the consumer-defined paths that MUST stay in exported archives.
+ *
+ * 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 list the configured keep-in-export paths
+ */
+ private function configuredKeepInExportPaths(): array
+ {
+ $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/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..9f6c0fd
--- /dev/null
+++ b/src/GitAttributes/CandidateProvider.php
@@ -0,0 +1,139 @@
+
+ * @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 [
+ '/.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/',
+ ];
+ }
+
+ /**
+ * @return list Files that are candidates for export-ignore
+ */
+ 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',
+ '/README.md',
+ '/SECURITY.md',
+ '/SUPPORT.md',
+ '/UPGRADE.md',
+ '/UPGRADING.md',
+ '/Vagrantfile',
+ '/bitbucket-pipelines.yml',
+ '/codecov.yml',
+ '/composer-normalize.json',
+ '/composer-require-checker.json',
+ '/context7.json',
+ '/docker-compose.override.yml',
+ '/docker-compose.yaml',
+ '/docker-compose.yml',
+ '/docker-bake.hcl',
+ '/docker-stack.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',
+ ];
+ }
+
+ /**
+ * 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..ae56256
--- /dev/null
+++ b/src/GitAttributes/ExistenceChecker.php
@@ -0,0 +1,102 @@
+
+ * @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
+{
+ /**
+ * @param Filesystem $filesystem
+ */
+ public function __construct(
+ private Filesystem $filesystem = new Filesystem()
+ ) {}
+
+ /**
+ * 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 $basePath, string $path): bool
+ {
+ 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(string $basePath, array $paths): array
+ {
+ 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 $basePath, string $path): bool
+ {
+ 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 $basePath, string $path): bool
+ {
+ return is_file($this->absolutePath($basePath, $path));
+ }
+
+ /**
+ * 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
new file mode 100644
index 0000000..9d24fe9
--- /dev/null
+++ b/src/GitAttributes/ExistenceCheckerInterface.php
@@ -0,0 +1,68 @@
+
+ * @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 $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 $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(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 $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 $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
new file mode 100644
index 0000000..1eb74ab
--- /dev/null
+++ b/src/GitAttributes/Merger.php
@@ -0,0 +1,324 @@
+
+ * @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_split;
+use function Safe\preg_replace;
+use function Safe\preg_match;
+
+/**
+ * Merges .gitattributes content with generated export-ignore rules.
+ *
+ * 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 class Merger implements MergerInterface
+{
+ /**
+ * 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 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')]);
+ }
+
+ /**
+ * Parses the raw .gitattributes content into trimmed non-empty lines.
+ *
+ * @param string $content The full .gitattributes content.
+ *
+ * @return list the non-empty lines from the file
+ */
+ private function parseExistingLines(string $content): array
+ {
+ if ('' === $content) {
+ return [];
+ }
+
+ $lines = [];
+
+ foreach (preg_split('/\R/', $content) ?: [] as $line) {
+ $trimmedLine = trim($line);
+
+ if ('' === $trimmedLine) {
+ continue;
+ }
+
+ $lines[] = $trimmedLine;
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Builds a lookup table for paths that MUST stay in the exported archive.
+ *
+ * @param list $keepInExportPaths the configured keep-in-export paths
+ *
+ * @return array the normalized path lookup
+ */
+ private function keepInExportLookup(array $keepInExportPaths): array
+ {
+ $lookup = [];
+
+ foreach ($keepInExportPaths as $path) {
+ $normalizedPath = $this->normalizePathKey($path);
+
+ if ('' === $normalizedPath) {
+ continue;
+ }
+
+ $lookup[$normalizedPath] = true;
+ }
+
+ return $lookup;
+ }
+
+ /**
+ * Builds a lookup table of generated directory candidates.
+ *
+ * @param list $exportIgnoreEntries the generated export-ignore path list
+ *
+ * @return array the normalized directory lookup
+ */
+ private function generatedDirectoryLookup(array $exportIgnoreEntries): array
+ {
+ $lookup = [];
+
+ foreach ($exportIgnoreEntries as $entry) {
+ $trimmedEntry = trim($entry);
+
+ if (! str_ends_with($trimmedEntry, '/')) {
+ continue;
+ }
+
+ $lookup[$this->normalizePathKey($trimmedEntry)] = true;
+ }
+
+ return $lookup;
+ }
+
+ /**
+ * Normalizes a .gitattributes line for deterministic comparison and output.
+ *
+ * @param string $line the raw line to normalize
+ *
+ * @return string the normalized line
+ */
+ private function normalizeLine(string $line): string
+ {
+ $trimmedLine = trim($line);
+
+ if ('' === $trimmedLine) {
+ return '';
+ }
+
+ if (str_starts_with($trimmedLine, '#')) {
+ return $trimmedLine;
+ }
+
+ return preg_replace('/(?normalizePathSpec($pathSpec), '/');
+ }
+
+ /**
+ * Normalizes a gitattributes path spec for sorting.
+ *
+ * @param string $pathSpec the raw path spec to normalize
+ *
+ * @return string the normalized path spec
+ */
+ private function normalizePathSpec(string $pathSpec): string
+ {
+ $trimmedPathSpec = trim($pathSpec);
+
+ if ('' === $trimmedPathSpec) {
+ return '';
+ }
+
+ $isDirectory = str_ends_with($trimmedPathSpec, '/');
+ $normalizedPathSpec = preg_replace('#/+#', '/', '/' . ltrim($trimmedPathSpec, '/')) ?? $trimmedPathSpec;
+ $normalizedPathSpec = '/' === $normalizedPathSpec ? $normalizedPathSpec : rtrim($normalizedPathSpec, '/');
+
+ if ($isDirectory && '/' !== $normalizedPathSpec) {
+ $normalizedPathSpec .= '/';
+ }
+
+ 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
new file mode 100644
index 0000000..f5477b7
--- /dev/null
+++ b/src/GitAttributes/MergerInterface.php
@@ -0,0 +1,41 @@
+
+ * @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 removing obsolete generated markers and
+ * duplicate lines.
+ */
+interface MergerInterface
+{
+ /**
+ * 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(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/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/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 683fddf..e429f50 100644
--- a/tests/Command/SyncCommandTest.php
+++ b/tests/Command/SyncCommandTest.php
@@ -20,9 +20,13 @@
use FastForward\DevTools\Command\GitIgnoreCommand;
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;
-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 +39,13 @@
#[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(ExportIgnoreFilter::class)]
+#[UsesClass(GitAttributesMerger::class)]
final class SyncCommandTest extends AbstractCommandTestCase
{
use ProphecyTrait;
@@ -63,7 +71,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 +79,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..81a7783 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,13 @@
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\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;
use PHPUnit\Framework\Attributes\Test;
@@ -53,10 +60,17 @@
#[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(ExportIgnoreFilter::class)]
+#[UsesClass(GitAttributesMerger::class)]
+#[UsesClass(GitAttributesReader::class)]
+#[UsesClass(GitAttributesWriter::class)]
+#[UsesClass(GitIgnoreMerger::class)]
#[UsesClass(Writer::class)]
final class DevToolsCommandProviderTest extends TestCase
{
@@ -89,6 +103,7 @@ public function getCommandsWillReturnAllSupportedCommandsInExpectedOrder(): void
new WikiCommand(),
new SyncCommand(),
new GitIgnoreCommand(),
+ new GitAttributesCommand(),
new SkillsCommand(),
new CopyLicenseCommand(),
],
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/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..6847de8
--- /dev/null
+++ b/tests/GitAttributes/MergerTest.php
@@ -0,0 +1,276 @@
+
+ * @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
+{
+ private Merger $merger;
+
+ /**
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ $this->merger = new Merger();
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function mergeWillCreateManagedBlockWhenFileIsEmpty(): void
+ {
+ $result = $this->merger->merge('', ['/docs/', '/README.md']);
+
+ self::assertSame("/docs/ export-ignore\n" . '/README.md export-ignore', $result);
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function mergeWillPreserveCustomEntries(): void
+ {
+ $existingContent = implode("\n", ['*.zip -diff', '*.phar binary']);
+
+ $result = $this->merger->merge($existingContent, ['/docs/']);
+
+ self::assertSame("*.zip -diff\n*.phar binary\n" . '/docs/ export-ignore', $result);
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function mergeWillDeduplicateExistingAndGeneratedEntries(): void
+ {
+ $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 = $this->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
+ {
+ $existingContent = implode("\n", [
+ '* text=auto',
+ '/.gitignore export-ignore',
+ 'AGENTS.md export-ignore',
+ '/README.md export-ignore',
+ ]);
+
+ $result = $this->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
+ {
+ $existingContent = implode("\n", [
+ '* text=auto',
+ '/README.md export-ignore',
+ '/docs/ export-ignore',
+ '/AGENTS.md export-ignore',
+ ]);
+
+ $result = $this->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,
+ );
+ }
+
+ /**
+ * @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/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..b0b7eb7
--- /dev/null
+++ b/tests/GitAttributes/WriterTest.php
@@ -0,0 +1,199 @@
+
+ * @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\Prophecy\ObjectProphecy;
+use Prophecy\PhpUnit\ProphecyTrait;
+use Symfony\Component\Filesystem\Filesystem;
+
+#[CoversClass(Writer::class)]
+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
+ {
+ $this->writer->write('/project/.gitattributes', '*.zip -diff');
+
+ $this->filesystem->dumpFile('/project/.gitattributes', "*.zip -diff\n")
+ ->shouldBeCalledOnce();
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function writeWillAlignAttributeColumnsUsingTheLongestPathSpec(): void
+ {
+ $this->writer->write(
+ '/project/.gitattributes',
+ implode("\n", ['* text=auto', '/.github/ export-ignore', '/.gitattributes export-ignore']),
+ );
+
+ $this->filesystem->dumpFile(
+ '/project/.gitattributes',
+ "* text=auto\n"
+ . "/.github/ export-ignore\n"
+ . "/.gitattributes export-ignore\n",
+ )
+ ->shouldBeCalledOnce();
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function writeWillRespectEscapedWhitespaceInsideThePathSpec(): void
+ {
+ $this->writer->write(
+ '/project/.gitattributes',
+ implode("\n", ['docs\ with\ spaces export-ignore', '/.github/ export-ignore']),
+ );
+
+ $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());
+ }
+}