Skip to content

Commit 44eb6b8

Browse files
authored
Merge pull request #21 from php-fast-forward/feature/12-sync-gitignore
[feature] Enhance dev-tools:sync to merge and normalize .gitignore entries
2 parents 9cecf52 + 3993e32 commit 44eb6b8

28 files changed

+1810
-13
lines changed

.github/wiki

Submodule wiki updated from 2c546cc to cfd1257

.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

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ composer dev-tools wiki
5858
# Generate documentation frontpage and related reports
5959
composer dev-tools reports
6060

61+
# Merges and synchronizes .gitignore files
62+
composer dev-tools gitignore
63+
6164
# Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and ensures the repository wiki is present as a git submodule in .github/wiki
6265
composer dev-tools:sync
6366
```

docs/api/commands.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch.
4242
* - ``FastForward\DevTools\Command\SyncCommand``
4343
- ``dev-tools:sync``
4444
- Synchronizes consumer-facing scripts and automation assets.
45+
* - ``FastForward\DevTools\Command\GitIgnoreCommand``
46+
- ``gitignore``
47+
- Merges and synchronizes .gitignore files.

docs/running/specialized-commands.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,24 @@ Important details:
138138
- it copies missing workflow stubs, ``.editorconfig``, and ``dependabot.yml``;
139139
- it creates ``.github/wiki`` as a git submodule when the directory is
140140
missing.
141+
- it calls ``gitignore`` to merge the canonical .gitignore with the project's .gitignore.
142+
143+
``gitignore``
144+
-------------
145+
146+
Merges and synchronizes .gitignore files.
147+
148+
.. code-block:: bash
149+
150+
composer dev-tools gitignore
151+
composer dev-tools gitignore -- --source=/path/to/source/.gitignore --target=/path/to/target/.gitignore
152+
153+
Important details:
154+
155+
- it reads the canonical .gitignore from dev-tools and merges with the
156+
project's existing .gitignore;
157+
- by default, the source is the packaged .gitignore and the target is the
158+
project's root .gitignore;
159+
- duplicates are removed and entries are sorted alphabetically;
160+
- it uses the Reader, Merger, and Writer components from the GitIgnore
161+
namespace.

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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
/**
44+
* @param WriterInterface $writer the writer component for handling .gitignore file writing
45+
*/
46+
private readonly WriterInterface $writer;
47+
48+
/**
49+
* Creates a new GitIgnoreCommand instance.
50+
*
51+
* @param Filesystem|null $filesystem the filesystem component
52+
* @param MergerInterface $merger the merger component
53+
* @param ReaderInterface $reader the reader component
54+
* @param WriterInterface|null $writer the writer component
55+
*/
56+
public function __construct(
57+
?Filesystem $filesystem = null,
58+
private readonly MergerInterface $merger = new Merger(),
59+
private readonly ReaderInterface $reader = new Reader(),
60+
?WriterInterface $writer = null
61+
) {
62+
parent::__construct($filesystem);
63+
$this->writer = $writer ?? new Writer($this->filesystem);
64+
}
65+
66+
/**
67+
* Configures the current command.
68+
*
69+
* This method MUST define the name, description, and help text for the command.
70+
* It SHALL identify the tool as the mechanism for script synchronization.
71+
*/
72+
protected function configure(): void
73+
{
74+
$this
75+
->setName('gitignore')
76+
->setDescription('Merges and synchronizes .gitignore files.')
77+
->setHelp(
78+
"This command merges the canonical .gitignore from dev-tools with the project's existing .gitignore."
79+
)
80+
->addOption(
81+
name: 'source',
82+
shortcut: 's',
83+
mode: InputOption::VALUE_OPTIONAL,
84+
description: 'Path to the source .gitignore file (canonical)',
85+
default: parent::getDevToolsFile('.gitignore'),
86+
)
87+
->addOption(
88+
name: 'target',
89+
shortcut: 't',
90+
mode: InputOption::VALUE_OPTIONAL,
91+
description: 'Path to the target .gitignore file (project)',
92+
default: parent::getConfigFile('.gitignore', true)
93+
);
94+
}
95+
96+
/**
97+
* Executes the gitignore merge process.
98+
*
99+
* @param InputInterface $input the input interface
100+
* @param OutputInterface $output the output interface
101+
*
102+
* @return int the status code
103+
*/
104+
protected function execute(InputInterface $input, OutputInterface $output): int
105+
{
106+
$output->writeln('<info>Merging .gitignore files...</info>');
107+
108+
$sourcePath = $input->getOption('source');
109+
$targetPath = $input->getOption('target');
110+
111+
$canonical = $this->reader->read($sourcePath);
112+
$project = $this->reader->read($targetPath);
113+
114+
$merged = $this->merger->merge($canonical, $project);
115+
116+
$this->writer->write($merged);
117+
118+
$output->writeln('<info>Successfully merged .gitignore file.</info>');
119+
120+
return self::SUCCESS;
121+
}
122+
}

src/Command/SyncCommand.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Composer\Factory;
2222
use Composer\Json\JsonManipulator;
2323
use Symfony\Component\Console\Input\InputInterface;
24+
use Symfony\Component\Console\Input\StringInput;
2425
use Symfony\Component\Console\Output\OutputInterface;
2526
use Symfony\Component\Filesystem\Path;
2627
use Symfony\Component\Finder\Finder;
@@ -74,6 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7475
$this->copyEditorConfig();
7576
$this->copyDependabotConfig();
7677
$this->addRepositoryWikiGitSubmodule();
78+
$this->syncGitIgnore($output);
7779

7880
return self::SUCCESS;
7981
}
@@ -233,4 +235,20 @@ private function getGitRepositoryUrl(): string
233235

234236
return trim($process->getOutput());
235237
}
238+
239+
/**
240+
* Synchronizes .gitignore entries from dev-tools into the target project.
241+
*
242+
* This method merges canonical .gitignore entries from the dev-tools package
243+
* with the target project's existing .gitignore entries, then writes the merged result.
244+
*
245+
* @param OutputInterface $output
246+
*
247+
* @return void
248+
*/
249+
private function syncGitIgnore(OutputInterface $output): void
250+
{
251+
$this->getApplication()
252+
->doRun(new StringInput('gitignore'), $output);
253+
}
236254
}

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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
use function Safe\preg_match;
22+
23+
/**
24+
* Classifies .gitignore entries as directory-oriented or file-oriented patterns.
25+
*
26+
* This classifier SHALL inspect a raw .gitignore entry and determine whether the
27+
* entry expresses directory semantics or file semantics. Implementations MUST
28+
* preserve deterministic classification for identical inputs. Blank entries and
29+
* comment entries MUST be treated as file-oriented values to avoid incorrectly
30+
* inferring directory intent where no effective pattern exists.
31+
*/
32+
final class Classifier implements ClassifierInterface
33+
{
34+
/**
35+
* Represents a classification result indicating directory semantics.
36+
*
37+
* This constant MUST be returned when an entry clearly targets a directory,
38+
* such as entries ending with a slash or patterns that imply directory
39+
* traversal.
40+
*/
41+
private const string DIRECTORY = 'directory';
42+
43+
/**
44+
* Represents a classification result indicating file semantics.
45+
*
46+
* This constant MUST be returned when an entry does not clearly express
47+
* directory semantics, including blank values and comment lines.
48+
*/
49+
private const string FILE = 'file';
50+
51+
/**
52+
* Classifies a .gitignore entry as either a directory or a file pattern.
53+
*
54+
* The provided entry SHALL be normalized with trim() before any rule is
55+
* evaluated. Empty entries and comment entries MUST be classified as files.
56+
* Entries ending with "/" MUST be classified as directories. Patterns that
57+
* indicate directory traversal or wildcard directory matching SHOULD also be
58+
* classified as directories.
59+
*
60+
* @param string $entry The raw .gitignore entry to classify.
61+
*
62+
* @return string The classification result. The value MUST be either
63+
* self::DIRECTORY or self::FILE.
64+
*/
65+
public function classify(string $entry): string
66+
{
67+
$entry = trim($entry);
68+
69+
if ('' === $entry) {
70+
return self::FILE;
71+
}
72+
73+
if (str_starts_with($entry, '#')) {
74+
return self::FILE;
75+
}
76+
77+
if (str_ends_with($entry, '/')) {
78+
return self::DIRECTORY;
79+
}
80+
81+
if (1 === preg_match('/^[^.*]+[\/*]+/', $entry)) {
82+
return self::DIRECTORY;
83+
}
84+
85+
if (str_starts_with($entry, '**/')) {
86+
return self::DIRECTORY;
87+
}
88+
89+
if (str_contains($entry, '*/')) {
90+
return self::DIRECTORY;
91+
}
92+
93+
return self::FILE;
94+
}
95+
96+
/**
97+
* Determines whether the given .gitignore entry represents a directory pattern.
98+
*
99+
* This method MUST delegate the effective classification to classify() and
100+
* SHALL return true only when the resulting classification is
101+
* self::DIRECTORY.
102+
*
103+
* @param string $entry The raw .gitignore entry to evaluate.
104+
*
105+
* @return bool true when the entry is classified as a directory pattern;
106+
* otherwise, false
107+
*/
108+
public function isDirectory(string $entry): bool
109+
{
110+
return self::DIRECTORY === $this->classify($entry);
111+
}
112+
113+
/**
114+
* Determines whether the given .gitignore entry represents a file pattern.
115+
*
116+
* This method MUST delegate the effective classification to classify() and
117+
* SHALL return true only when the resulting classification is self::FILE.
118+
*
119+
* @param string $entry The raw .gitignore entry to evaluate.
120+
*
121+
* @return bool true when the entry is classified as a file pattern;
122+
* otherwise, false
123+
*/
124+
public function isFile(string $entry): bool
125+
{
126+
return self::FILE === $this->classify($entry);
127+
}
128+
}

0 commit comments

Comments
 (0)