8000 [Finder] Fix recursive .gitignore support for Windows and root direct… · symfony/symfony@229d961 · GitHub
[go: up one dir, main page]

Skip to content

Commit 229d961

Browse files
committed
[Finder] Fix recursive .gitignore support for Windows and root directory search
1 parent 7244d83 commit 229d961

File tree

10 files changed

+242
-24
lines changed

10 files changed

+242
-24
lines changed

src/Symfony/Component/Finder/Gitignore.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,22 @@ class Gitignore
2626
*/
2727
public static function toRegex(string $gitignoreFileContent): string
2828
{
29+
return self::buildRegex($gitignoreFileContent, false);
30+
}
31+
32+
public static function toRegexMatchingNegatedPatterns(string $gitignoreFileContent): string
33+
{
34+
return self::buildRegex($gitignoreFileContent, true);
35+
}
36+
37+
private static function buildRegex(string $gitignoreFileContent, bool $inverted): string
38+
{
39+
2940
$gitignoreFileContent = preg_replace('~(?<!\\\\)#[^\n\r]*~', '', $gitignoreFileContent);
3041
$gitignoreLines = preg_split('~\r\n?|\n~', $gitignoreFileContent);
3142

3243
$res = self::lineToRegex('');
33-
foreach ($gitignoreLines as $i => $line) {
44+
foreach ($gitignoreLines as $line) {
3445
$line = preg_replace('~(?<!\\\\)[ \t]+$~', '', $line);
3546

3647
if ('!' === substr($line, 0, 1)) {
@@ -41,7 +52,7 @@ public static function toRegex(string $gitignoreFileContent): string
4152
}
4253

4354
if ('' !== $line) {
44-
if ($isNegative) {
55+
if ($isNegative xor $inverted) {
4556
$res = '(?!'.self::lineToRegex($line).'$)'.$res;
4657
} else {
4758
$res = '(?:'.$res.'|'.self::lineToRegex($line).')';

src/Symfony/Component/Finder/Iterator/VcsIgnoredFilterIterator.php

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,18 @@ final class VcsIgnoredFilterIterator extends \FilterIterator
2121
private $baseDir;
2222

2323
/**
24-
* @var array<string, string|null>
24+
* @var array<string, array{0: string, 1: string}|null>
2525 B41A
*/
2626
private $gitignoreFilesCache = [];
2727

28+
/**
29+
* @var array<string, bool>
30+
*/
31+
private $ignoredPathsCache = [];
32+
2833
public function __construct(\Iterator $iterator, string $baseDir)
2934
{
30-
$this->baseDir = $baseDir;
35+
$this->baseDir = $this->normalizePath($baseDir);
3136

3237
parent::__construct($iterator);
3338
}
@@ -36,41 +41,115 @@ public function accept(): bool
3641
{
3742
$file = $this->current();
3843

39-
$fileRealPath = $file->getRealPath();
40-
if ($file->isDir()) {
44+
$fileRealPath = $this->normalizePath($file->getRealPath());
45+
46+
return !$this->isIgnored($fileRealPath);
47+
}
48+
49+
private function isIgnored(string $fileRealPath): bool
50+
{
51+
if (is_dir($fileRealPath) && !str_ends_with($fileRealPath, '/')) {
4152
$fileRealPath .= '/';
4253
}
4354

55+
if (isset($this->ignoredPathsCache[$fileRealPath])) {
56+
return $this->ignoredPathsCache[$fileRealPath];
57+
}
58+
59+
$ignored = false;
60+
61+
foreach ($this->parentsDirectoryDownward($fileRealPath) as $parentDirectory) {
62+
if ($this->isIgnored($parentDirectory)) {
63+
$ignored = true;
64+
65+
// rules in ignored directories are ignored, no need to check further.
66+
break;
67+
}
68+
69+
$fileRelativePath = substr($fileRealPath, \strlen($parentDirectory) + 1);
70+
71+
$regexps = $this->readGitignoreFile("{$parentDirectory}/.gitignore");
72+
73+
if (null === $regexps) {
74+
continue;
75+
}
76+
77+
[$exclusionRegex, $inclusionRegex] = $regexps;
78+
79+
if (preg_match($exclusionRegex, $fileRelativePath)) {
80+
$ignored = true;
81+
82+
continue;
83+
}
84+
85+
if (preg_match($inclusionRegex, $fileRelativePath)) {
86+
$ignored = false;
87+
}
88+
}
89+
90+
return $this->ignoredPathsCache[$fileRealPath] = $ignored;
91+
}
92+
93+
/**
94+
* @return list<string>
95+
*/
96+
private function parentsDirectoryDownward(string $fileRealPath): array
97+
{
98+
$parentDirectories = [];
99+
44100
$parentDirectory = $fileRealPath;
45101

46-
do {
47-
$parentDirectory = \dirname($parentDirectory);
48-
$relativeFilePath = substr($fileRealPath, \strlen($parentDirectory) + 1);
102+
while (true) {
103+
$newParentDirectory = \dirname($parentDirectory);
104+
105+
// dirname('/') = '/'
106+
if ($newParentDirectory === $parentDirectory) {
107+
break;
108+
}
49109

50-
$regex = $this->readGitignoreFile("{$parentDirectory}/.gitignore");
110+
$parentDirectory = $newParentDirectory;
51111

52-
if (null !== $regex && preg_match($regex, $relativeFilePath)) {
53-
return false;
112+
if (0 !== strpos($parentDirectory, $this->baseDir)) {
113+
break;
54114
}
55-
} while ($parentDirectory !== $this->baseDir);
56115

57-
return true;
116+
$parentDirectories[] = $parentDirectory;
117+
}
118+
119+
return array_reverse($parentDirectories);
58120
}
59121

60-
private function readGitignoreFile(string $path): ?string
122+
/**
123+
* @return array{0: string, 1: string}|null
124+
*/
125+
private function readGitignoreFile(string $path): ?array
61126
{
62127
if (\array_key_exists($path, $this->gitignoreFilesCache)) {
63128
return $this->gitignoreFilesCache[$path];
64129
}
65130

66131
if (!file_exists($path)) {
67-
return null;
132+
return $this->gitignoreFilesCache[$path] = null;
68133
}
69134

70135
if (!is_file($path) || !is_readable($path)) {
71136
throw new \RuntimeException("The \"ignoreVCSIgnored\" option cannot be used by the Finder as the \"{$path}\" file is not readable.");
72137
}
73138

74-
return $this->gitignoreFilesCache[$path] = Gitignore::toRegex(file_get_contents($path));
139+
$gitignoreFileContent = file_get_contents($path);
140+
141+
return $this->gitignoreFilesCache[$path] = [
142+
Gitignore::toRegex($gitignoreFileContent),
143+
Gitignore::toRegexMatchingNegatedPatterns($gitignoreFileContent),
144+
];
145+
}
146+
147+
private function normalizePath(string $path): string
148+
{
149+
if ('\\' === \DIRECTORY_SEPARATOR) {
150+
return str_replace('\\', '/', $path);
151+
}
152+
153+
return $path;
75154
}
76155
}

src/Symfony/Component/Finder/Tests/FinderTest.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -433,14 +433,18 @@ public function testIgnoreVCSIgnored()
433433
->ignoreVCSIgnored(true)
434434
);
435435

436-
copy(__DIR__.'/Fixtures/gitignore/b.txt', __DIR__.'/Fixtures/gitignore/a.txt');
437-
copy(__DIR__.'/Fixtures/gitignore/dir/a.txt', __DIR__.'/Fixtures/gitignore/dir/b.txt');
436+
copy(__DIR__.'/Fixtures/gitignore/search_root/b.txt', __DIR__.'/Fixtures/gitignore/search_root/a.txt');
437+
copy(__DIR__.'/Fixtures/gitignore/search_root/b.txt', __DIR__.'/Fixtures/gitignore/search_root/c.txt');
438+
copy(__DIR__.'/Fixtures/gitignore/search_root/dir/a.txt', __DIR__.'/Fixtures/gitignore/search_root/dir/b.txt');
439+
copy(__DIR__.'/Fixtures/gitignore/search_root/dir/a.txt', __DIR__.'/Fixtures/gitignore/search_root/dir/c.txt');
438440

439441
$this->assertIterator($this->toAbsoluteFixtures([
440-
'gitignore/b.txt',
441-
'gitignore/dir',
442-
'gitignore/dir/a.txt',
443-
]), $finder->in(__DIR__.'/Fixtures/gitignore')->getIterator());
442+
'gitignore/search_root/b.txt',
443+
'gitignore/search_root/c.txt',
444+
'gitignore/search_root/dir',
445+
'gitignore/search_root/dir/a.txt',
446+
'gitignore/search_root/dir/c.txt',
447+
]), $finder->in(__DIR__.'/Fixtures/gitignore/search_root')->getIterator());
444448
}
445449

446450
public function testIgnoreVCSCanBeDisabledAfterFirstIteration()
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
/a.txt
1+
c.txt
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/a.txt

src/Symfony/Component/Finder/Tests/GitignoreTest.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,4 +442,71 @@ public function providerExtended(): array
442442

443443
return $cases;
444444
}
445+
446+
/**
447+
* @dataProvider provideNegatedPatternsCases
448+
*/
449+
public function testToRegexMatchingNegatedPatterns(array $gitignoreLines, array $matchingCases, array $nonMatchingCases)
450+
{
451+
$patterns = implode("\n", $gitignoreLines);
452+
453+
$regex = Gitignore::toRegexMatchingNegatedPatterns($patterns);
454+
$this->assertSame($regex, Gitignore::toRegexMatchingNegatedPatterns(implode("\r\n", $gitignoreLines)));
455+
$this->assertSame($regex, Gitignore::toRegexMatchingNegatedPatterns(implode("\r", $gitignoreLines)));
456+
457+
foreach ($matchingCases as $matchingCase) {
458+
$this->assertMatchesRegularExpression(
459+
$regex,
460+
$matchingCase,
461+
sprintf(
462+
"Failed asserting path:\n%s\nmatches gitignore negated patterns:\n%s",
463+
preg_replace('~^~m', ' ', $matchingCase),
464+
preg_replace('~^~m', ' ', $patterns)
465+
)
466+
);
467+
}
468+
469+
foreach ($nonMatchingCases as $nonMatchingCase) {
470+
$this->assertDoesNotMatchRegularExpression(
471+
$regex,
472+
$nonMatchingCase,
473+
sprintf("Failed asserting path:\n%s\nNOT matching gitignore negated patterns:\n%s",
474+
preg_replace('~^~m', ' ', $nonMatchingCase),
475+
preg_replace('~^~m', ' ', $patterns)
476+
)
477+
);
478+
}
479+
}
480+
481+
public function provideNegatedPatternsCases(): iterable
482+
{
483+
yield [
484+
[''],
485+
[],
486+
['a', 'a/b', 'a/b/c', 'aa', 'm.txt', '.txt'],
487+
];
488+
489+
yield [
490+
['a'],
491+
[],
492+
['a', 'a/b', 'a/b/c', 'aa', 'm.txt', '.txt'],
493+
];
494+
495+
yield [
496+
['!a'],
497+
['a', 'a/b', 'a/b/c'],
498+
['aa', 'm.txt', '.txt'],
499+
];
500+
501+
yield [
502+
[
503+
'/*',
504+
'!/foo',
505+
'/foo/*',
506+
'!/foo/bar',
507+
],
508+
['foo', 'foo/bar'],
509+
['bar', 'foo/ba', 'foo/barx', 'x/foo/bar'],
510+
];
511+
}
445512
}

src/Symfony/Component/Finder/Tests/Iterator/VcsIgnoredFilterIteratorTest.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,62 @@ public function getAcceptData(): iterable
200200
'nested/nested/dir/a.txt',
201201
],
202202
];
203+
204+
yield 'negated pattern in nested .gitignore' => [
205+
[
206+
'.gitignore' => '*.txt',
207+
'nested/.gitignore' => "!a.txt\ndir/",
208+
],
209+
[
210+
'a.txt',
211+
'b.txt',
212+
'nested/a.txt',
213+
'nested/b.txt',
214+
'nested/dir/a.txt',
215+
'nested/dir/b.txt',
216+
],
217+
[
218+
'nested/a.txt',
219+
],
220+
];
221+
222+
yield 'negated pattern in ignored nested .gitignore' => [
223+
[
224+
'.gitignore' => "*.txt\n/nested/",
225+
'nested/.gitignore' => "!a.txt\ndir/",
226+
],
227+
[
228+
'a.txt',
229+
'b.txt',
230+
'nested/a.txt',
231+
'nested/b.txt',
232+
'nested/dir/a.txt',
233+
'nested/dir/b.txt',
234+
],
235+
[],
236+
];
237+
238+
yield 'directory pattern negated in a subdirectory' => [
239+
[
240+
'.gitignore' => "c/",
241+
'a/.gitignore' => "!c/",
242+
],
243+
[
244+
'a/b/c/d.txt',
245+
],
246+
[
247+
'a/b/c/d.txt',
248+
],
249+
];
250+
}
251+
252+
public function testAcceptAtRootDirectory()
253+
{
254+
$inner = new InnerNameIterator([__FILE__]);
255+
256+
$iterator = new VcsIgnoredFilterIterator($inner, '/');
257+
258+
$this->assertIterator([__FILE__], $iterator);
203259
}
204260

205261
private function toAbsolute(array $files): array

0 commit comments

Comments
 (0)
0