Skip to content

Commit 2997326

Browse files
committed
feat(gitignore): introduce GitIgnoreInterface and related classes for .gitignore management
- Added GitIgnoreInterface to define the contract for .gitignore files. - Implemented Merger and MergerInterface for merging and deduplicating .gitignore entries. - Created Reader and ReaderInterface for reading .gitignore files. - Developed Writer and WriterInterface for writing .gitignore files. - Updated existing classes to utilize the new interfaces and improve functionality. - Added tests for GitIgnore, Merger, Reader, and Writer to ensure correct behavior. - Enhanced command tests to cover new GitIgnoreCommand functionality. Signed-off-by: Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
1 parent 2674281 commit 2997326

24 files changed

Lines changed: 1412 additions & 105 deletions

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ backup/
44
public/
55
tmp/
66
vendor/
7+
*.cache
78
.DS_Store
89
composer.lock
9-
*.cache
10-
TODO.md

src/Command/AbstractCommand.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
namespace FastForward\DevTools\Command;
2020

21+
use RuntimeException;
2122
use Symfony\Component\Console\Helper\ProcessHelper;
2223
use Composer\Command\BaseCommand;
2324
use Symfony\Component\Console\Input\ArrayInput;
@@ -112,8 +113,12 @@ protected function runProcess(Process $command, OutputInterface $output): int
112113
*/
113114
protected function getCurrentWorkingDirectory(): string
114115
{
115-
return $this->getApplication()
116-
->getInitialWorkingDirectory() ?: getcwd();
116+
try {
117+
return $this->getApplication()
118+
->getInitialWorkingDirectory() ?: getcwd();
119+
} catch (RuntimeException) {
120+
return getcwd();
121+
}
117122
}
118123

119124
/**

src/Command/GitIgnoreCommand.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\Command;
20+
21+
use FastForward\DevTools\GitIgnore\Merger;
22+
use FastForward\DevTools\GitIgnore\MergerInterface;
23+
use FastForward\DevTools\GitIgnore\Reader;
24+
use FastForward\DevTools\GitIgnore\ReaderInterface;
25+
use FastForward\DevTools\GitIgnore\Writer;
26+
use FastForward\DevTools\GitIgnore\WriterInterface;
27+
use Symfony\Component\Console\Input\InputInterface;
28+
use Symfony\Component\Console\Input\InputOption;
29+
use Symfony\Component\Console\Output\OutputInterface;
30+
use Symfony\Component\Filesystem\Filesystem;
31+
32+
/**
33+
* Provides functionality to merge and synchronize .gitignore files.
34+
*
35+
* This command merges the canonical .gitignore from dev-tools with the project's
36+
* existing .gitignore, removing duplicates and sorting entries.
37+
*
38+
* The command accepts two options: --source and --target to specify the paths
39+
* to the canonical and project .gitignore files respectively.
40+
*/
41+
final class GitIgnoreCommand extends AbstractCommand
42+
{
43+
private readonly WriterInterface $writer;
44+
45+
/**
46+
* Creates a new GitIgnoreCommand instance.
47+
*
48+
* @param Filesystem|null $filesystem the filesystem component
49+
* @param MergerInterface $merger the merger component
50+
* @param ReaderInterface $reader the reader component
51+
* @param WriterInterface|null $writer the writer component
52+
*/
53+
public function __construct(
54+
?Filesystem $filesystem = null,
55+
private readonly MergerInterface $merger = new Merger(),
56+
private readonly ReaderInterface $reader = new Reader(),
57+
?WriterInterface $writer = null
58+
) {
59+
parent::__construct($filesystem);
60+
$this->writer = $writer ?? new Writer($this->filesystem);
61+
}
62+
63+
/**
64+
* Configures the current command.
65+
*
66+
* This method MUST define the name, description, and help text for the command.
67+
* It SHALL identify the tool as the mechanism for script synchronization.
68+
*/
69+
protected function configure(): void
70+
{
71+
$this
72+
->setName('gitignore')
73+
->setDescription('Merges and synchronizes .gitignore files.')
74+
->setHelp(
75+
"This command merges the canonical .gitignore from dev-tools with the project's existing .gitignore."
76+
)
77+
->addOption(
78+
name: 'source',
79+
shortcut: 's',
80+
mode: InputOption::VALUE_OPTIONAL,
81+
description: 'Path to the source .gitignore file (canonical)',
82+
default: parent::getDevToolsFile('.gitignore'),
83+
)
84+
->addOption(
85+
name: 'target',
86+
shortcut: 't',
87+
mode: InputOption::VALUE_OPTIONAL,
88+
description: 'Path to the target .gitignore file (project)',
89+
default: parent::getConfigFile('.gitignore', true)
90+
);
91+
}
92+
93+
/**
94+
* Executes the gitignore merge process.
95+
*
96+
* @param InputInterface $input the input interface
97+
* @param OutputInterface $output the output interface
98+
*
99+
* @return int the status code
100+
*/
101+
protected function execute(InputInterface $input, OutputInterface $output): int
102+
{
103+
$output->writeln('<info>Merging .gitignore files...</info>');
104+
105+
$sourcePath = $input->getOption('source');
106+
$targetPath = $input->getOption('target');
107+
108+
$canonical = $this->reader->read($sourcePath);
109+
$project = $this->reader->read($targetPath);
110+
111+
$merged = $this->merger->merge($canonical, $project);
112+
113+
$this->writer->write($merged);
114+
115+
$output->writeln('<info>Successfully merged .gitignore file.</info>');
116+
117+
return self::SUCCESS;
118+
}
119+
}

src/Command/SyncCommand.php

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,9 @@
1919
namespace FastForward\DevTools\Command;
2020

2121
use Composer\Factory;
22-
use FastForward\DevTools\GitIgnore\Classifier;
23-
use FastForward\DevTools\GitIgnore\Merger;
24-
use FastForward\DevTools\GitIgnore\Reader;
25-
use FastForward\DevTools\GitIgnore\Writer;
2622
use Composer\Json\JsonManipulator;
2723
use Symfony\Component\Console\Input\InputInterface;
24+
use Symfony\Component\Console\Input\StringInput;
2825
use Symfony\Component\Console\Output\OutputInterface;
2926
use Symfony\Component\Filesystem\Path;
3027
use Symfony\Component\Finder\Finder;
@@ -39,11 +36,6 @@
3936
final class SyncCommand extends AbstractCommand
4037
{
4138
/**
42-
* Configures the current command.
43-
*
44-
* This method MUST define the name, description, and help text for the command.
45-
* It SHALL identify the tool as the mechanism for script synchronization.
46-
*
4739
* @return void
4840
*/
4941
protected function configure(): void
@@ -78,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7870
$this->copyEditorConfig();
7971
$this->copyDependabotConfig();
8072
$this->addRepositoryWikiGitSubmodule();
81-
$this->syncGitIgnore();
73+
$this->syncGitIgnore($output);
8274

8375
return self::SUCCESS;
8476
}
@@ -245,22 +237,13 @@ private function getGitRepositoryUrl(): string
245237
* This method merges canonical .gitignore entries from the dev-tools package
246238
* with the target project's existing .gitignore entries, then writes the merged result.
247239
*
240+
* @param OutputInterface $output
241+
*
248242
* @return void
249243
*/
250-
private function syncGitIgnore(): void
244+
private function syncGitIgnore(OutputInterface $output): void
251245
{
252-
$packagePath = parent::getDevToolsFile('');
253-
$projectPath = $this->getCurrentWorkingDirectory();
254-
$targetPath = $projectPath . '/.gitignore';
255-
256-
$canonicalEntries = Reader::readFromPackage($packagePath);
257-
$projectEntries = Reader::readFromProject($projectPath);
258-
259-
$classifier = new Classifier();
260-
$merger = new Merger($classifier);
261-
$mergedEntries = $merger->merge($canonicalEntries, $projectEntries);
262-
263-
$writer = new Writer($this->filesystem);
264-
$writer->write($mergedEntries, $targetPath);
246+
$this->getApplication()
247+
->doRun(new StringInput('gitignore'), $output);
265248
}
266249
}

src/Composer/Capability/DevToolsCommandProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
2323
use FastForward\DevTools\Command\CodeStyleCommand;
2424
use FastForward\DevTools\Command\DocsCommand;
25+
use FastForward\DevTools\Command\GitIgnoreCommand;
2526
use FastForward\DevTools\Command\PhpDocCommand;
2627
use FastForward\DevTools\Command\RefactorCommand;
2728
use FastForward\DevTools\Command\ReportsCommand;
@@ -56,6 +57,7 @@ public function getCommands()
5657
new ReportsCommand(),
5758
new WikiCommand(),
5859
new SyncCommand(),
60+
new GitIgnoreCommand(),
5961
];
6062
}
6163
}

src/GitIgnore/Classifier.php

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,16 @@
2323
/**
2424
* Classifies .gitignore entries as directories or files.
2525
*/
26-
final class Classifier
26+
final class Classifier implements ClassifierInterface
2727
{
2828
private const string DIRECTORY = 'directory';
2929

3030
private const string FILE = 'file';
3131

3232
/**
33-
* Classifies a .gitignore entry as directory or file pattern.
34-
*
35-
* @param string $entry the .gitignore entry
33+
* @param string $entry
3634
*
37-
* @return 'directory'|'file' the classification
35+
* @return string
3836
*/
3937
public function classify(string $entry): string
4038
{
@@ -52,35 +50,35 @@ public function classify(string $entry): string
5250
return self::DIRECTORY;
5351
}
5452

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

59-
if (str_contains($entry, '*/')) {
57+
if (str_starts_with($entry, '**/')) {
6058
return self::DIRECTORY;
6159
}
6260

63-
if (str_starts_with($entry, '**/')) {
61+
if (str_contains($entry, '*/')) {
6462
return self::DIRECTORY;
6563
}
6664

6765
return self::FILE;
6866
}
6967

7068
/**
71-
* Checks if an entry is a directory pattern.
72-
*
7369
* @param string $entry
70+
*
71+
* @return bool
7472
*/
7573
public function isDirectory(string $entry): bool
7674
{
7775
return self::DIRECTORY === $this->classify($entry);
7876
}
7977

8078
/**
81-
* Checks if an entry is a file pattern.
82-
*
8379
* @param string $entry
80+
*
81+
* @return bool
8482
*/
8583
public function isFile(string $entry): bool
8684
{
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\GitIgnore;
20+
21+
/**
22+
* Defines the contract for classifying .gitignore entries.
23+
*/
24+
interface ClassifierInterface
25+
{
26+
/**
27+
* Classifies a .gitignore entry as directory or file pattern.
28+
*
29+
* @param string $entry the .gitignore entry to classify
30+
*
31+
* @return 'directory'|'file' the classification result
32+
*/
33+
public function classify(string $entry): string;
34+
35+
/**
36+
* Determines whether the entry represents a directory pattern.
37+
*
38+
* @param string $entry the .gitignore entry to check
39+
*
40+
* @return bool true if the entry is a directory pattern
41+
*/
42+
public function isDirectory(string $entry): bool;
43+
44+
/**
45+
* Determines whether the entry represents a file pattern.
46+
*
47+
* @param string $entry the .gitignore entry to check
48+
*
49+
* @return bool true if the entry is a file pattern
50+
*/
51+
public function isFile(string $entry): bool;
52+
}

0 commit comments

Comments
 (0)