Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
22 changes: 22 additions & 0 deletions docs/running/specialized-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
-------------

Expand Down
185 changes: 185 additions & 0 deletions src/Command/GitAttributesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<?php

declare(strict_types=1);

/**
* This file is part of fast-forward/dev-tools.
*
* This source file is subject to the license bundled
* with this source code in the file LICENSE.
*
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
* @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('<info>Synchronizing .gitattributes export-ignore rules...</info>');

$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(
'<comment>No candidate paths found in repository. Skipping .gitattributes sync.</comment>'
);

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(
'<info>Added %d export-ignore entries to .gitattributes.</info>',
\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<string> 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));
}
}
5 changes: 3 additions & 2 deletions src/Command/SyncCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
);
}

Expand All @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions src/Composer/Capability/DevToolsCommandProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +63,7 @@ public function getCommands()
new WikiCommand(),
new SyncCommand(),
new GitIgnoreCommand(),
new GitAttributesCommand(),
new SkillsCommand(),
new CopyLicenseCommand(),
];
Expand Down
Loading
Loading