Skip to content

Commit

Permalink
feature #30448 [Finder] Ignore paths from .gitignore #26714 (amaabdou)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the 4.3-dev branch (closes #30448).

Discussion
----------

 [Finder] Ignore paths from .gitignore #26714

| Q             | A
| ------------- | ---
| Branch?       | master for features
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #26714
| License       | MIT
| Doc PR        | symfony/symfony-docs#... <!-- required for new features -->

<!--
Write a short README entry for your feature/bugfix here (replace this comment block.)
This will help people understand your PR and can be used as a start of the Doc PR.
Additionally:
 - Bug fixes must be submitted against the lowest branch where they apply
   (lowest branches are regularly merged to upper ones so they get the fixes too).
 - Features and deprecations must be submitted against the master branch.
-->
Implementation of feature request #26714

Finder::ignoreVCS() is great at ignoring file patterns for the files created by popular VCS systems.

However, it would be great to be able to instruct Finder to actually exclude the paths excluded by .gitignore.

So if we have .gitignore:

vendor/
cache/

Finder::create()
	->files()
    ->ignoreVCS(true) // <--- Ignores `.git`
	->ignoreVCSIgnored(true); // <--- Ignores vendor/ and cache/

Commits
-------

9491393  [Finder] Ignore paths from .gitignore #26714
  • Loading branch information
fabpot committed Mar 22, 2019
2 parents 7e30c97 + 9491393 commit 0b2a9d5
Show file tree
Hide file tree
Showing 8 changed files with 297 additions and 1 deletion.
5 changes: 5 additions & 0 deletions 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
-----

Expand Down
27 changes: 27 additions & 0 deletions src/Symfony/Component/Finder/Finder.php
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;

Expand Down
107 changes: 107 additions & 0 deletions src/Symfony/Component/Finder/Gitignore.php
@@ -0,0 +1,107 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <mail@ahmd.io>
*/
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;
}
}
34 changes: 33 additions & 1 deletion src/Symfony/Component/Finder/Tests/FinderTest.php
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -421,13 +424,36 @@ 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();
$finder->in(self::$tmpDir);
$finder->ignoreDotFiles(false);

$this->assertIterator($this->toAbsolute([
'.gitignore',
'foo',
'foo/bar.tmp',
'qux',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -574,6 +604,7 @@ public function testIgnoreDotFilesCanBeDisabledAfterFirstIteration()

$finder->ignoreDotFiles(false);
$this->assertIterator($this->toAbsolute([
'.gitignore',
'foo',
'foo/bar.tmp',
'qux',
Expand Down Expand Up @@ -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',
Expand Down
118 changes: 118 additions & 0 deletions src/Symfony/Component/Finder/Tests/GitignoreTest.php
@@ -0,0 +1,118 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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'],
],
];
}
}

0 comments on commit 0b2a9d5

Please sign in to comment.