diff --git a/src/Symfony/Component/Finder/CHANGELOG.md b/src/Symfony/Component/Finder/CHANGELOG.md index 9abccfc237f4..2045184e8333 100644 --- a/src/Symfony/Component/Finder/CHANGELOG.md +++ b/src/Symfony/Component/Finder/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +4.3.0 +----- + + * added Finder::ignoreVCSIgnored() to ignore files based on rules listed in .gitignore + 4.2.0 ----- diff --git a/src/Symfony/Component/Finder/Finder.php b/src/Symfony/Component/Finder/Finder.php index 83163f51d52e..f791f8cf32a8 100644 --- a/src/Symfony/Component/Finder/Finder.php +++ b/src/Symfony/Component/Finder/Finder.php @@ -39,6 +39,7 @@ class Finder implements \IteratorAggregate, \Countable { const IGNORE_VCS_FILES = 1; const IGNORE_DOT_FILES = 2; + const IGNORE_VCS_IGNORED_FILES = 4; private $mode = 0; private $names = []; @@ -373,6 +374,24 @@ public function ignoreVCS($ignoreVCS) return $this; } + /** + * Forces Finder to obey .gitignore and ignore files based on rules listed there. + * + * This option is disabled by default. + * + * @return $this + */ + public function ignoreVCSIgnored(bool $ignoreVCSIgnored) + { + if ($ignoreVCSIgnored) { + $this->ignore |= static::IGNORE_VCS_IGNORED_FILES; + } else { + $this->ignore &= ~static::IGNORE_VCS_IGNORED_FILES; + } + + return $this; + } + /** * Adds VCS patterns. * @@ -685,6 +704,14 @@ private function searchInDirectory(string $dir): \Iterator $notPaths[] = '#(^|/)\..+(/|$)#'; } + if (static::IGNORE_VCS_IGNORED_FILES === (static::IGNORE_VCS_IGNORED_FILES & $this->ignore)) { + $gitignoreFilePath = sprintf('%s/.gitignore', $dir); + if (!is_readable($gitignoreFilePath)) { + throw new \RuntimeException(sprintf('The "ignoreVCSIgnored" option cannot be used by the Finder as the "%s" file is not readable.', $gitignoreFilePath)); + } + $notPaths = array_merge($notPaths, [Gitignore::toRegex(file_get_contents($gitignoreFilePath))]); + } + $minDepth = 0; $maxDepth = PHP_INT_MAX; diff --git a/src/Symfony/Component/Finder/Gitignore.php b/src/Symfony/Component/Finder/Gitignore.php new file mode 100644 index 000000000000..fbeabdbd520b --- /dev/null +++ b/src/Symfony/Component/Finder/Gitignore.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Finder; + +/** + * Gitignore matches against text. + * + * @author Ahmed Abdou + */ +class Gitignore +{ + /** + * Returns a regexp which is the equivalent of the gitignore pattern. + * + * @param string $gitignoreFileContent + * + * @return string The regexp + */ + public static function toRegex(string $gitignoreFileContent): string + { + $gitignoreFileContent = preg_replace('/^[^\\\\]*#.*/', '', $gitignoreFileContent); + $gitignoreLines = preg_split('/\r\n|\r|\n/', $gitignoreFileContent); + $gitignoreLines = array_map('trim', $gitignoreLines); + $gitignoreLines = array_filter($gitignoreLines); + + $ignoreLinesPositive = array_filter($gitignoreLines, function (string $line) { + return !preg_match('/^!/', $line); + }); + + $ignoreLinesNegative = array_filter($gitignoreLines, function (string $line) { + return preg_match('/^!/', $line); + }); + + $ignoreLinesNegative = array_map(function (string $line) { + return preg_replace('/^!(.*)/', '${1}', $line); + }, $ignoreLinesNegative); + $ignoreLinesNegative = array_map([__CLASS__, 'getRegexFromGitignore'], $ignoreLinesNegative); + + $ignoreLinesPositive = array_map([__CLASS__, 'getRegexFromGitignore'], $ignoreLinesPositive); + if (empty($ignoreLinesPositive)) { + return '/^$/'; + } + + if (empty($ignoreLinesNegative)) { + return sprintf('/%s/', implode('|', $ignoreLinesPositive)); + } + + return sprintf('/(?=^(?:(?!(%s)).)*$)(%s)/', implode('|', $ignoreLinesNegative), implode('|', $ignoreLinesPositive)); + } + + private static function getRegexFromGitignore(string $gitignorePattern): string + { + $regex = '('; + if (0 === strpos($gitignorePattern, '/')) { + $gitignorePattern = substr($gitignorePattern, 1); + $regex .= '^'; + } else { + $regex .= '(^|\/)'; + } + + if ('/' === $gitignorePattern[\strlen($gitignorePattern) - 1]) { + $gitignorePattern = substr($gitignorePattern, 0, -1); + } + + $iMax = \strlen($gitignorePattern); + for ($i = 0; $i < $iMax; ++$i) { + $doubleChars = substr($gitignorePattern, $i, 2); + if ('**' === $doubleChars) { + $regex .= '.+'; + ++$i; + continue; + } + + $c = $gitignorePattern[$i]; + switch ($c) { + case '*': + $regex .= '[^\/]+'; + break; + case '/': + case '.': + case ':': + case '(': + case ')': + case '{': + case '}': + $regex .= '\\'.$c; + break; + default: + $regex .= $c; + } + } + + $regex .= '($|\/)'; + $regex .= ')'; + + return $regex; + } +} diff --git a/src/Symfony/Component/Finder/Tests/FinderTest.php b/src/Symfony/Component/Finder/Tests/FinderTest.php index 9217cb7190b5..9899c61fa6a5 100644 --- a/src/Symfony/Component/Finder/Tests/FinderTest.php +++ b/src/Symfony/Component/Finder/Tests/FinderTest.php @@ -347,6 +347,7 @@ public function testIgnoreVCS() $finder = $this->buildFinder(); $this->assertSame($finder, $finder->ignoreVCS(false)->ignoreDotFiles(false)); $this->assertIterator($this->toAbsolute([ + '.gitignore', '.git', 'foo', 'foo/bar.tmp', @@ -373,6 +374,7 @@ public function testIgnoreVCS() $finder = $this->buildFinder(); $finder->ignoreVCS(false)->ignoreVCS(false)->ignoreDotFiles(false); $this->assertIterator($this->toAbsolute([ + '.gitignore', '.git', 'foo', 'foo/bar.tmp', @@ -399,6 +401,7 @@ public function testIgnoreVCS() $finder = $this->buildFinder(); $this->assertSame($finder, $finder->ignoreVCS(true)->ignoreDotFiles(false)); $this->assertIterator($this->toAbsolute([ + '.gitignore', 'foo', 'foo/bar.tmp', 'test.php', @@ -421,6 +424,28 @@ public function testIgnoreVCS() ]), $finder->in(self::$tmpDir)->getIterator()); } + public function testIgnoreVCSIgnored() + { + $finder = $this->buildFinder(); + $this->assertSame( + $finder, + $finder + ->ignoreVCS(true) + ->ignoreDotFiles(true) + ->ignoreVCSIgnored(true) + ); + $this->assertIterator($this->toAbsolute([ + 'foo', + 'foo/bar.tmp', + 'test.py', + 'toto', + 'foo bar', + 'qux', + 'qux/baz_100_1.py', + 'qux/baz_1_2.py', + ]), $finder->in(self::$tmpDir)->getIterator()); + } + public function testIgnoreVCSCanBeDisabledAfterFirstIteration() { $finder = $this->buildFinder(); @@ -428,6 +453,7 @@ public function testIgnoreVCSCanBeDisabledAfterFirstIteration() $finder->ignoreDotFiles(false); $this->assertIterator($this->toAbsolute([ + '.gitignore', 'foo', 'foo/bar.tmp', 'qux', @@ -450,7 +476,9 @@ public function testIgnoreVCSCanBeDisabledAfterFirstIteration() ]), $finder->getIterator()); $finder->ignoreVCS(false); - $this->assertIterator($this->toAbsolute(['.git', + $this->assertIterator($this->toAbsolute([ + '.gitignore', + '.git', 'foo', 'foo/bar.tmp', 'qux', @@ -479,6 +507,7 @@ public function testIgnoreDotFiles() $finder = $this->buildFinder(); $this->assertSame($finder, $finder->ignoreDotFiles(false)->ignoreVCS(false)); $this->assertIterator($this->toAbsolute([ + '.gitignore', '.git', '.bar', '.foo', @@ -505,6 +534,7 @@ public function testIgnoreDotFiles() $finder = $this->buildFinder(); $finder->ignoreDotFiles(false)->ignoreDotFiles(false)->ignoreVCS(false); $this->assertIterator($this->toAbsolute([ + '.gitignore', '.git', '.bar', '.foo', @@ -574,6 +604,7 @@ public function testIgnoreDotFilesCanBeDisabledAfterFirstIteration() $finder->ignoreDotFiles(false); $this->assertIterator($this->toAbsolute([ + '.gitignore', 'foo', 'foo/bar.tmp', 'qux', @@ -842,6 +873,7 @@ public function testIn() $expected = [ self::$tmpDir.\DIRECTORY_SEPARATOR.'test.php', + __DIR__.\DIRECTORY_SEPARATOR.'GitignoreTest.php', __DIR__.\DIRECTORY_SEPARATOR.'FinderTest.php', __DIR__.\DIRECTORY_SEPARATOR.'GlobTest.php', self::$tmpDir.\DIRECTORY_SEPARATOR.'qux_0_1.php', diff --git a/src/Symfony/Component/Finder/Tests/GitignoreTest.php b/src/Symfony/Component/Finder/Tests/GitignoreTest.php new file mode 100644 index 000000000000..fca846d86e48 --- /dev/null +++ b/src/Symfony/Component/Finder/Tests/GitignoreTest.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Finder\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Finder\Gitignore; + +class GitignoreTest extends TestCase +{ + /** + * @dataProvider provider + * + * @param string $patterns + * @param array $matchingCases + * @param array $nonMatchingCases + */ + public function testCases(string $patterns, array $matchingCases, array $nonMatchingCases) + { + $regex = Gitignore::toRegex($patterns); + + foreach ($matchingCases as $matchingCase) { + $this->assertRegExp($regex, $matchingCase, sprintf('Failed asserting path [%s] matches gitignore patterns [%s] using regex [%s]', $matchingCase, $patterns, $regex)); + } + + foreach ($nonMatchingCases as $nonMatchingCase) { + $this->assertNotRegExp($regex, $nonMatchingCase, sprintf('Failed asserting path [%s] not matching gitignore patterns [%s] using regex [%s]', $nonMatchingCase, $patterns, $regex)); + } + } + + /** + * @return array return is array of + * [ + * [ + * '', // Git-ignore Pattern + * [], // array of file paths matching + * [], // array of file paths not matching + * ], + * ] + */ + public function provider() + { + return [ + [ + ' + * + !/bin/bash + ', + ['bin/cat', 'abc/bin/cat'], + ['bin/bash'], + ], + [ + 'fi#le.txt', + [], + ['#file.txt'], + ], + [ + ' + /bin/ + /usr/local/ + !/bin/bash + !/usr/local/bin/bash + ', + ['bin/cat'], + ['bin/bash'], + ], + [ + '*.py[co]', + ['file.pyc', 'file.pyc'], + ['filexpyc', 'file.pycx', 'file.py'], + ], + [ + 'dir1/**/dir2/', + ['dir1/dirA/dir2/', 'dir1/dirA/dirB/dir2/'], + [], + ], + [ + 'dir1/*/dir2/', + ['dir1/dirA/dir2/'], + ['dir1/dirA/dirB/dir2/'], + ], + [ + '/*.php', + ['file.php'], + ['app/file.php'], + ], + [ + '\#file.txt', + ['#file.txt'], + [], + ], + [ + '*.php', + ['app/file.php', 'file.php'], + ['file.phps', 'file.phps', 'filephps'], + ], + [ + 'app/cache/', + ['app/cache/file.txt', 'app/cache/dir1/dir2/file.txt', 'a/app/cache/file.txt'], + [], + ], + [ + ' + #IamComment + /app/cache/', + ['app/cache/file.txt', 'app/cache/subdir/ile.txt'], + ['a/app/cache/file.txt'], + ], + ]; + } +} diff --git a/src/Symfony/Component/Finder/Tests/Iterator/DepthRangeFilterIteratorTest.php b/src/Symfony/Component/Finder/Tests/Iterator/DepthRangeFilterIteratorTest.php index ef4fa2e9ac8d..7c2572d21047 100644 --- a/src/Symfony/Component/Finder/Tests/Iterator/DepthRangeFilterIteratorTest.php +++ b/src/Symfony/Component/Finder/Tests/Iterator/DepthRangeFilterIteratorTest.php @@ -33,6 +33,7 @@ public function testAccept($minDepth, $maxDepth, $expected) public function getAcceptData() { $lessThan1 = [ + '.gitignore', '.git', 'test.py', 'foo', @@ -51,6 +52,7 @@ public function getAcceptData() ]; $lessThanOrEqualTo1 = [ + '.gitignore', '.git', 'test.py', 'foo', diff --git a/src/Symfony/Component/Finder/Tests/Iterator/ExcludeDirectoryFilterIteratorTest.php b/src/Symfony/Component/Finder/Tests/Iterator/ExcludeDirectoryFilterIteratorTest.php index 1729766fe4bd..dbf461f60e85 100644 --- a/src/Symfony/Component/Finder/Tests/Iterator/ExcludeDirectoryFilterIteratorTest.php +++ b/src/Symfony/Component/Finder/Tests/Iterator/ExcludeDirectoryFilterIteratorTest.php @@ -31,6 +31,7 @@ public function testAccept($directories, $expected) public function getAcceptData() { $foo = [ + '.gitignore', '.bar', '.foo', '.foo/.bar', @@ -53,6 +54,7 @@ public function getAcceptData() ]; $fo = [ + '.gitignore', '.bar', '.foo', '.foo/.bar', @@ -77,6 +79,7 @@ public function getAcceptData() ]; $toto = [ + '.gitignore', '.bar', '.foo', '.foo/.bar', diff --git a/src/Symfony/Component/Finder/Tests/Iterator/RealIteratorTestCase.php b/src/Symfony/Component/Finder/Tests/Iterator/RealIteratorTestCase.php index bd14167ab833..4f4ba016a718 100644 --- a/src/Symfony/Component/Finder/Tests/Iterator/RealIteratorTestCase.php +++ b/src/Symfony/Component/Finder/Tests/Iterator/RealIteratorTestCase.php @@ -63,6 +63,8 @@ public static function setUpBeforeClass() file_put_contents(self::toAbsolute('test.php'), str_repeat(' ', 800)); file_put_contents(self::toAbsolute('test.py'), str_repeat(' ', 2000)); + file_put_contents(self::toAbsolute('.gitignore'), '*.php'); + touch(self::toAbsolute('foo/bar.tmp'), strtotime('2005-10-15')); touch(self::toAbsolute('test.php'), strtotime('2005-10-15')); }