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
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from 2c546c to cfd125
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ backup/
public/
tmp/
vendor/
*.cache
.DS_Store
composer.lock
*.cache
TODO.md
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ composer dev-tools wiki
# Generate documentation frontpage and related reports
composer dev-tools reports

# Merges and synchronizes .gitignore files
composer dev-tools gitignore

# Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and ensures the repository wiki is present as a git submodule in .github/wiki
composer dev-tools:sync
```
Expand Down
3 changes: 3 additions & 0 deletions docs/api/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch.
* - ``FastForward\DevTools\Command\SyncCommand``
- ``dev-tools:sync``
- Synchronizes consumer-facing scripts and automation assets.
* - ``FastForward\DevTools\Command\GitIgnoreCommand``
- ``gitignore``
- Merges and synchronizes .gitignore files.
21 changes: 21 additions & 0 deletions docs/running/specialized-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,24 @@ Important details:
- it copies missing workflow stubs, ``.editorconfig``, and ``dependabot.yml``;
- it creates ``.github/wiki`` as a git submodule when the directory is
missing.
- it calls ``gitignore`` to merge the canonical .gitignore with the project's .gitignore.

``gitignore``
-------------

Merges and synchronizes .gitignore files.

.. code-block:: bash

composer dev-tools gitignore
composer dev-tools gitignore -- --source=/path/to/source/.gitignore --target=/path/to/target/.gitignore

Important details:

- it reads the canonical .gitignore from dev-tools and merges with the
project's existing .gitignore;
- by default, the source is the packaged .gitignore and the target is the
project's root .gitignore;
- duplicates are removed and entries are sorted alphabetically;
- it uses the Reader, Merger, and Writer components from the GitIgnore
namespace.
9 changes: 7 additions & 2 deletions src/Command/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

namespace FastForward\DevTools\Command;

use RuntimeException;
use Symfony\Component\Console\Helper\ProcessHelper;
use Composer\Command\BaseCommand;
use Symfony\Component\Console\Input\ArrayInput;
Expand Down Expand Up @@ -112,8 +113,12 @@ protected function runProcess(Process $command, OutputInterface $output): int
*/
protected function getCurrentWorkingDirectory(): string
{
return $this->getApplication()
->getInitialWorkingDirectory() ?: getcwd();
try {
return $this->getApplication()
->getInitialWorkingDirectory() ?: getcwd();
} catch (RuntimeException) {
return getcwd();
}
}

/**
Expand Down
122 changes: 122 additions & 0 deletions src/Command/GitIgnoreCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?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\GitIgnore\Merger;
use FastForward\DevTools\GitIgnore\MergerInterface;
use FastForward\DevTools\GitIgnore\Reader;
use FastForward\DevTools\GitIgnore\ReaderInterface;
use FastForward\DevTools\GitIgnore\Writer;
use FastForward\DevTools\GitIgnore\WriterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;

/**
* Provides functionality to merge and synchronize .gitignore files.
*
* This command merges the canonical .gitignore from dev-tools with the project's
* existing .gitignore, removing duplicates and sorting entries.
*
* The command accepts two options: --source and --target to specify the paths
* to the canonical and project .gitignore files respectively.
*/
final class GitIgnoreCommand extends AbstractCommand
{
/**
* @param WriterInterface $writer the writer component for handling .gitignore file writing
*/
private readonly WriterInterface $writer;

/**
* Creates a new GitIgnoreCommand instance.
*
* @param Filesystem|null $filesystem the filesystem component
* @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 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.
* It SHALL identify the tool as the mechanism for script synchronization.
*/
protected function configure(): void
{
$this
->setName('gitignore')
->setDescription('Merges and synchronizes .gitignore files.')
->setHelp(
"This command merges the canonical .gitignore from dev-tools with the project's existing .gitignore."
)
->addOption(
name: 'source',
shortcut: 's',
mode: InputOption::VALUE_OPTIONAL,
description: 'Path to the source .gitignore file (canonical)',
default: parent::getDevToolsFile('.gitignore'),
)
->addOption(
name: 'target',
shortcut: 't',
mode: InputOption::VALUE_OPTIONAL,
description: 'Path to the target .gitignore file (project)',
default: parent::getConfigFile('.gitignore', true)
);
}

/**
* Executes the gitignore merge process.
*
* @param InputInterface $input the input interface
* @param OutputInterface $output the output interface
*
* @return int the status code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('<info>Merging .gitignore files...</info>');

$sourcePath = $input->getOption('source');
$targetPath = $input->getOption('target');

$canonical = $this->reader->read($sourcePath);
$project = $this->reader->read($targetPath);

$merged = $this->merger->merge($canonical, $project);

$this->writer->write($merged);

$output->writeln('<info>Successfully merged .gitignore file.</info>');

return self::SUCCESS;
}
}
18 changes: 18 additions & 0 deletions src/Command/SyncCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Composer\Factory;
use Composer\Json\JsonManipulator;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Finder\Finder;
Expand Down Expand Up @@ -74,6 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->copyEditorConfig();
$this->copyDependabotConfig();
$this->addRepositoryWikiGitSubmodule();
$this->syncGitIgnore($output);

return self::SUCCESS;
}
Expand Down Expand Up @@ -233,4 +235,20 @@ private function getGitRepositoryUrl(): string

return trim($process->getOutput());
}

/**
* Synchronizes .gitignore entries from dev-tools into the target project.
*
* This method merges canonical .gitignore entries from the dev-tools package
* with the target project's existing .gitignore entries, then writes the merged result.
*
* @param OutputInterface $output
*
* @return void
*/
private function syncGitIgnore(OutputInterface $output): void
{
$this->getApplication()
->doRun(new StringInput('gitignore'), $output);
}
}
2 changes: 2 additions & 0 deletions src/Composer/Capability/DevToolsCommandProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
use FastForward\DevTools\Command\CodeStyleCommand;
use FastForward\DevTools\Command\DocsCommand;
use FastForward\DevTools\Command\GitIgnoreCommand;
use FastForward\DevTools\Command\PhpDocCommand;
use FastForward\DevTools\Command\RefactorCommand;
use FastForward\DevTools\Command\ReportsCommand;
Expand Down Expand Up @@ -56,6 +57,7 @@ public function getCommands()
new ReportsCommand(),
new WikiCommand(),
new SyncCommand(),
new GitIgnoreCommand(),
];
}
}
128 changes: 128 additions & 0 deletions src/GitIgnore/Classifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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\GitIgnore;

use function Safe\preg_match;

/**
* Classifies .gitignore entries as directory-oriented or file-oriented patterns.
*
* This classifier SHALL inspect a raw .gitignore entry and determine whether the
* entry expresses directory semantics or file semantics. Implementations MUST
* preserve deterministic classification for identical inputs. Blank entries and
* comment entries MUST be treated as file-oriented values to avoid incorrectly
* inferring directory intent where no effective pattern exists.
*/
final class Classifier implements ClassifierInterface
{
/**
* Represents a classification result indicating directory semantics.
*
* This constant MUST be returned when an entry clearly targets a directory,
* such as entries ending with a slash or patterns that imply directory
* traversal.
*/
private const string DIRECTORY = 'directory';

/**
* Represents a classification result indicating file semantics.
*
* This constant MUST be returned when an entry does not clearly express
* directory semantics, including blank values and comment lines.
*/
private const string FILE = 'file';

/**
* Classifies a .gitignore entry as either a directory or a file pattern.
*
* The provided entry SHALL be normalized with trim() before any rule is
* evaluated. Empty entries and comment entries MUST be classified as files.
* Entries ending with "/" MUST be classified as directories. Patterns that
* indicate directory traversal or wildcard directory matching SHOULD also be
* classified as directories.
*
* @param string $entry The raw .gitignore entry to classify.
*
* @return string The classification result. The value MUST be either
* self::DIRECTORY or self::FILE.
*/
public function classify(string $entry): string
{
$entry = trim($entry);

if ('' === $entry) {
return self::FILE;
}

if (str_starts_with($entry, '#')) {
return self::FILE;
}

if (str_ends_with($entry, '/')) {
return self::DIRECTORY;
}

if (1 === preg_match('/^[^.*]+[\/*]+/', $entry)) {
return self::DIRECTORY;
}

if (str_starts_with($entry, '**/')) {
return self::DIRECTORY;
}

if (str_contains($entry, '*/')) {
return self::DIRECTORY;
}

return self::FILE;
}

/**
* Determines whether the given .gitignore entry represents a directory pattern.
*
* This method MUST delegate the effective classification to classify() and
* SHALL return true only when the resulting classification is
* self::DIRECTORY.
*
* @param string $entry The raw .gitignore entry to evaluate.
*
* @return bool true when the entry is classified as a directory pattern;
* otherwise, false
*/
public function isDirectory(string $entry): bool
{
return self::DIRECTORY === $this->classify($entry);
}

/**
* Determines whether the given .gitignore entry represents a file pattern.
*
* This method MUST delegate the effective classification to classify() and
* SHALL return true only when the resulting classification is self::FILE.
*
* @param string $entry The raw .gitignore entry to evaluate.
*
* @return bool true when the entry is classified as a file pattern;
* otherwise, false
*/
public function isFile(string $entry): bool
{
return self::FILE === $this->classify($entry);
}
}
Loading
Loading