diff --git a/src/Command/Status/Porcelain/PathList.php b/src/Command/Status/Porcelain/PathList.php new file mode 100644 index 0000000..ae1371b --- /dev/null +++ b/src/Command/Status/Porcelain/PathList.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Status\Porcelain; + +use SebastianFeldmann\Cli\Command\OutputFormatter; +use SebastianFeldmann\Git\Status\Path; + +/** + * Class PathList + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release ?.?.? + */ +class PathList implements OutputFormatter +{ + /** + * Nul-byte used as a separator in `--porcelain=v1 -z` output. + */ + private const NUL_BYTE = "\x00"; + + /** + * Format the output. + * + * @param array $output + * @return iterable + */ + public function format(array $output): iterable + { + if (empty($output)) { + return []; + } + + $statusLine = implode('', $output); + $paths = []; + + foreach ($this->parseStatusLine($statusLine) as $pathParts) { + $paths[] = new Path(...$pathParts); + } + + return $paths; + } + + /** + * Parse the status line and return a 3-tuple of path parts. + * + * - 0: status code + * - 1: path + * - 2: original path, if renamed or copied + * + * @return array{ + * 0: non-empty-string, + * 1: non-empty-string, + * 2: non-empty-string|null + * } + */ + private function parseStatusLine(string $statusLine): array + { + $pathParts = []; + + $parts = array_reverse($this->splitOnNulByte($statusLine)); + + while ($parts) { + $part = array_pop($parts); + $statusCode = substr($part, 0, 2); + $path = substr($part, 3); + + $originalPath = null; + if (in_array($statusCode[0], [Path::COPIED, Path::RENAMED])) { + $originalPath = array_pop($parts); + } + + $pathParts[] = [$statusCode, $path, $originalPath]; + } + + return $pathParts; + } + + /** + * Split the status line on the nul-byte. + * + * @param string $statusLine + * @return array + */ + private function splitOnNulByte(string $statusLine): array + { + return explode(self::NUL_BYTE, trim($statusLine, self::NUL_BYTE)); + } +} diff --git a/src/Command/Status/WorkingTreeStatus.php b/src/Command/Status/WorkingTreeStatus.php new file mode 100644 index 0000000..241ccef --- /dev/null +++ b/src/Command/Status/WorkingTreeStatus.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Status; + +use SebastianFeldmann\Git\Command\Base; + +/** + * Class GetWorkingTreeStatus + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release ?.?.? + */ +class WorkingTreeStatus extends Base +{ + /** + * Ignore submodules. + * + * @var string + */ + private $ignoreSubmodules = ''; + + /** + * Set ignore submodules. + * + * @param bool $bool + * + * @return \SebastianFeldmann\Git\Command\Status\WorkingTreeStatus + */ + public function ignoreSubmodules(bool $bool = true): WorkingTreeStatus + { + $this->ignoreSubmodules = $this->useOption('--ignore-submodules', $bool); + return $this; + } + + /** + * Return the command to execute. + * + * @return string + * @throws \RuntimeException + */ + protected function getGitCommand(): string + { + return 'status --porcelain=v1 -z' + . $this->ignoreSubmodules; + } +} diff --git a/src/Operator/Status.php b/src/Operator/Status.php new file mode 100644 index 0000000..fdd9eb3 --- /dev/null +++ b/src/Operator/Status.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use SebastianFeldmann\Git\Command\Status\WorkingTreeStatus; +use SebastianFeldmann\Git\Command\Status\Porcelain\PathList; + +/** + * Class Status + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release ?.?.? + */ +class Status extends Base +{ + /** + * Returns a list of paths in the working tree and index, with statuses. + * + * @return \SebastianFeldmann\Git\Status\Path[] + */ + public function getWorkingTreeStatus(): iterable + { + $cmd = (new WorkingTreeStatus($this->repo->getRoot()))->ignoreSubmodules(); + + $result = $this->runner->run($cmd, new PathList()); + + return $result->getFormattedOutput(); + } +} diff --git a/src/Repository.php b/src/Repository.php index 35faf2e..b64054f 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -203,6 +203,16 @@ public function getDiffOperator(): Operator\Diff return $this->getOperator('Diff'); } + /** + * Get status operator. + * + * @return \SebastianFeldmann\Git\Operator\Status + */ + public function getStatusOperator(): Operator\Status + { + return $this->getOperator('Status'); + } + /** * Return requested operator. * diff --git a/src/Status/Path.php b/src/Status/Path.php new file mode 100644 index 0000000..34b0016 --- /dev/null +++ b/src/Status/Path.php @@ -0,0 +1,349 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Status; + +/** + * Class Path + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release ?.?.? + * @link https://git-scm.com/docs/git-status#_output git-status status codes + */ +class Path +{ + public const UNMODIFIED = "\x20"; + public const MODIFIED = 'M'; + public const ADDED = 'A'; + public const DELETED = 'D'; + public const RENAMED = 'R'; + public const COPIED = 'C'; + public const UPDATED_UNMERGED = 'U'; + public const UNTRACKED = '??'; + public const IGNORED = '!!'; + + /** + * Status code tuple. + * + * We initialize each item in the tuple with a single space (U+0020), + * since a space is a valid character, meaning "unmodified," in the + * `git status` output. + * + * @var array{0: string, 1: string} + */ + private $statusCode = [self::UNMODIFIED, self::UNMODIFIED]; + + /** + * Path. + * + * @var string + */ + private $path = ''; + + /** + * Original path, if this is a copied or renamed path. + * + * @var string|null + */ + private $originalPath = null; + + /** + * Path constructor. + * + * @param string $statusCode + * @param string $path + * @param string|null $originalPath + */ + public function __construct(string $statusCode, string $path, ?string $originalPath = null) + { + $this->statusCode[0] = $statusCode[0] ?? self::UNMODIFIED; + $this->statusCode[1] = $statusCode[1] ?? self::UNMODIFIED; + $this->path = $path; + $this->originalPath = $originalPath; + } + + /** + * Returns the status code tuple. + * + * @return array{0: string, 1: string} + */ + public function getStatusCode(): array + { + return $this->statusCode; + } + + /** + * Returns the status code as it appears in the raw `git status` output. + * + * @return string + */ + public function getRawStatusCode(): string + { + return implode('', $this->statusCode); + } + + /** + * Returns the path. + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Returns the original path, if this is a copied or renamed path. + * + * @return string|null + */ + public function getOriginalPath(): ?string + { + return $this->originalPath; + } + + /** + * Returns true if the path is not updated in the index. + * + * @return bool + */ + public function isNotUpdated(): bool + { + return $this->getStatusCode()[0] === self::UNMODIFIED; + } + + /** + * Returns true if the path is updated in the index. + * + * @return bool + */ + public function isUpdatedInIndex(): bool + { + return $this->getStatusCode()[0] === self::MODIFIED; + } + + /** + * Returns true if the path is a new file added to the index. + * + * @return bool + */ + public function isAddedToIndex(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[0] === self::ADDED; + } + + /** + * Return true if the path is deleted from the index. + * + * @return bool + */ + public function isDeletedFromIndex(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[0] === self::DELETED; + } + + /** + * Returns true if the path is renamed in the index. + * + * @return bool + */ + public function isRenamedInIndex(): bool + { + return $this->getStatusCode()[0] === self::RENAMED; + } + + /** + * Returns true if the path is copied in the index. + * + * @return bool + */ + public function isCopiedInIndex(): bool + { + return $this->getStatusCode()[0] === self::COPIED; + } + + /** + * Returns true if the path in the index matches that in the working tree. + * + * @return bool + */ + public function doesIndexMatchWorkingTree(): bool + { + return $this->getStatusCode()[1] === self::UNMODIFIED; + } + + /** + * Returns true if the path in the working tree has changes + * that are not in the index. + * + * @return bool + */ + public function hasWorkingTreeChangedSinceIndex(): bool + { + return $this->getStatusCode()[1] === self::MODIFIED; + } + + /** + * Returns true if the path is deleted in the working tree + * but not in the index. + * + * @return bool + */ + public function isDeletedInWorkingTree(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[1] === self::DELETED; + } + + /** + * Returns true if the path is renamed in the working tree + * but not in the index. + * + * @return bool + */ + public function isRenamedInWorkingTree(): bool + { + return $this->getStatusCode()[1] === self::RENAMED; + } + + /** + * Returns true if the path is copied in the working tree + * but not in the index. + * + * @return bool + */ + public function isCopiedInWorkingTree(): bool + { + return $this->getStatusCode()[1] === self::COPIED; + } + + /** + * Returns true if the path is added in the working tree + * but not in the index (a.k.a. intent to add). + * + * @return bool + */ + public function isAddedInWorkingTree(): bool + { + return !$this->isUnmerged() && $this->getStatusCode()[1] === self::ADDED; + } + + /** + * Returns true if there is currently a merge conflict + * and the path needs conflicts resolved. + * + * @return bool + */ + public function isUnmerged(): bool + { + return in_array(self::UPDATED_UNMERGED, $this->getStatusCode()) + || $this->areBothDeleted() + || $this->areBothAdded(); + } + + /** + * Returns true if the path is in conflict and deleted by each head of the merge. + * + * @return bool + */ + public function areBothDeleted(): bool + { + return $this->getStatusCode()[0] === self::DELETED + && $this->getStatusCode()[1] === self::DELETED; + } + + /** + * Returns true if the path is in conflict and added by each head of the merge. + * + * @return bool + */ + public function areBothAdded(): bool + { + return $this->getStatusCode()[0] === self::ADDED + && $this->getStatusCode()[1] === self::ADDED; + } + + /** + * Returns true if the path is in conflict and modified by each head of the merge. + * + * @return bool + */ + public function areBothModified(): bool + { + return $this->getStatusCode()[0] === self::UPDATED_UNMERGED + && $this->getStatusCode()[1] === self::UPDATED_UNMERGED; + } + + /** + * Returns true if the path is in conflict and added by us. + * + * @return bool + */ + public function isAddedByUs(): bool + { + return $this->getStatusCode()[0] === self::ADDED + && $this->getStatusCode()[1] === self::UPDATED_UNMERGED; + } + + /** + * Returns true if the path is in conflict and deleted by us. + * + * @return bool + */ + public function isDeletedByUs(): bool + { + return $this->getStatusCode()[0] === self::DELETED + && $this->getStatusCode()[1] === self::UPDATED_UNMERGED; + } + + /** + * Returns true if the path is in conflict and added by them. + * + * @return bool + */ + public function isAddedByThem(): bool + { + return $this->getStatusCode()[0] === self::UPDATED_UNMERGED + && $this->getStatusCode()[1] === self::ADDED; + } + + /** + * Returns true if the path is in conflict and deleted by them. + * + * @return bool + */ + public function isDeletedByThem(): bool + { + return $this->getStatusCode()[0] === self::UPDATED_UNMERGED + && $this->getStatusCode()[1] === self::DELETED; + } + + /** + * Returns true if the path is untracked. + * + * @return bool + */ + public function isUntracked(): bool + { + return $this->getRawStatusCode() === self::UNTRACKED; + } + + /** + * Returns true if the path is ignored. + * + * @return bool + */ + public function isIgnored(): bool + { + return $this->getRawStatusCode() === self::IGNORED; + } +} diff --git a/tests/git/Command/Status/Porcelain/PathListTest.php b/tests/git/Command/Status/Porcelain/PathListTest.php new file mode 100644 index 0000000..9125e5d --- /dev/null +++ b/tests/git/Command/Status/Porcelain/PathListTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Status\Porcelain; + +use PHPUnit\Framework\TestCase; +use SebastianFeldmann\Git\Status\Path; + +/** + * Class PathListTest + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release ?.?.? + */ +class PathListTest extends TestCase +{ + public function testFormat(): void + { + $output = [ + 'R LICENSE.md', + 'LICENSE', + 'A LICENSE.txt', + ' M composer.json', + 'AD composer.json.original', + ' A foo bar.txt', + 'C some-file.md', + 'some-file.txt', + 'M phpstan.neon', + ' M src/Operator/Index.php', + 'M src/Repository.php', + '?? foo"bar.txt', + '?? føö bár.txt', + '?? src/Command/Status/', + '?? src/Operator/Status.php', + '?? src/Status/', + '?? tests/git/Command/Status/', + '?? tests/git/Status/', + ]; + + // Combine into a NUL-byte separated string to represent status output + // using `--porcelain=v1 -z`. + $output = implode("\x00", $output) . "\x00"; + + $formatter = new PathList(); + $formatted = $formatter->format([$output]); + + $this->assertContainsOnlyInstancesOf(Path::class, $formatted); + } + + public function testFormatWithEmptyOutput(): void + { + $formatter = new PathList(); + $formatted = $formatter->format([]); + + $this->assertSame([], $formatted); + } +} diff --git a/tests/git/Command/Status/WorkingTreeStatusTest.php b/tests/git/Command/Status/WorkingTreeStatusTest.php new file mode 100644 index 0000000..3e25c5f --- /dev/null +++ b/tests/git/Command/Status/WorkingTreeStatusTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Command\Status; + +use PHPUnit\Framework\TestCase; + +/** + * Class GetWorkingTreeStatusTest + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release ?.?.? + */ +class WorkingTreeStatusTest extends TestCase +{ + public function testWorkingTreeStatus() + { + $status = new WorkingTreeStatus(); + + $this->assertSame('git status --porcelain=v1 -z', $status->getCommand()); + } + + public function testWorkingTreeStatusIgnoringSubmodules() + { + $status = (new WorkingTreeStatus())->ignoreSubmodules(); + + $this->assertSame('git status --porcelain=v1 -z --ignore-submodules', $status->getCommand()); + } +} diff --git a/tests/git/Operator/StatusTest.php b/tests/git/Operator/StatusTest.php new file mode 100644 index 0000000..7e624e0 --- /dev/null +++ b/tests/git/Operator/StatusTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianFeldmann\Git\Operator; + +use SebastianFeldmann\Cli\Command\Result as CommandResult; +use SebastianFeldmann\Cli\Command\Runner\Result as RunnerResult; +use SebastianFeldmann\Git\Command\Status\Porcelain\PathList; +use SebastianFeldmann\Git\Command\Status\WorkingTreeStatus; +use SebastianFeldmann\Git\Status\Path; + +/** + * Class StatusTest + * + * @package SebastianFeldmann\Git + * @author Sebastian Feldmann + * @link https://github.com/sebastianfeldmann/git + * @since Class available since Release ?.?.? + */ +class StatusTest extends OperatorTest +{ + public function testGetWorkingTreeStatus(): void + { + $root = (string) realpath(__FILE__ . '/../../..'); + $out = [ + new Path('M ', 'file1.ext'), + new Path('A ', 'file2.ext'), + ]; + + $repo = $this->getRepoMock(); + $runner = $this->getRunnerMock(); + $cmdRes = new CommandResult('git ...', 0); + $runRes = new RunnerResult($cmdRes, $out); + $gitCmd = (new WorkingTreeStatus($root))->ignoreSubmodules(); + + $repo->method('getRoot')->willReturn($root); + $runner->expects($this->once()) + ->method('run') + ->with( + $this->equalTo($gitCmd), + $this->equalTo(new PathList()) + ) + ->willReturn($runRes); + + $status = new Status($runner, $repo); + $paths = $status->getWorkingTreeStatus(); + + $this->assertIsArray($paths); + $this->assertCount(2, $paths); + $this->assertContainsOnlyInstancesOf(Path::class, $paths); + } +} diff --git a/tests/git/RepositoryTest.php b/tests/git/RepositoryTest.php index a4813ae..680c162 100644 --- a/tests/git/RepositoryTest.php +++ b/tests/git/RepositoryTest.php @@ -17,6 +17,7 @@ use SebastianFeldmann\Git\Operator\Index; use SebastianFeldmann\Git\Operator\Info; use SebastianFeldmann\Git\Operator\Log; +use SebastianFeldmann\Git\Operator\Status; /** * Class RepositoryTest @@ -237,4 +238,18 @@ public function testGetDiffOperator(DummyRepo $repo) $this->assertInstanceOf(Diff::class, $operator); } + + /** + * Tests Repository::getStatusOperator + * + * @dataProvider repoProvider + * @param DummyRepo $repo + */ + public function testGetStatusOperator(DummyRepo $repo) + { + $repository = new Repository($repo->getPath()); + $operator = $repository->getStatusOperator(); + + $this->assertInstanceOf(Status::class, $operator); + } } diff --git a/tests/git/Status/PathTest.php b/tests/git/Status/PathTest.php new file mode 100644 index 0000000..79e63f7 --- /dev/null +++ b/tests/git/Status/PathTest.php @@ -0,0 +1,199 @@ +assertSame('file.ext', $path->getPath()); + } + + public function testGetOriginalPathReturnsNull(): void + { + $path = new Path('M ', 'file.ext'); + + $this->assertNull($path->getOriginalPath()); + } + + public function testGetOriginalPath(): void + { + $path = new Path('R ', 'file.ext', 'originalFile.ext'); + + $this->assertSame('originalFile.ext', $path->getOriginalPath()); + } + + /** + * @dataProvider statusConditionsProvider + */ + public function testStatusConditions(string $status, array $methodsExpectedTrue): void + { + $path = new Path($status, 'file.ext'); + + $this->assertSame($status, $path->getRawStatusCode()); + $this->assertSame([$status[0], $status[1]], $path->getStatusCode()); + + foreach ($this->getBooleanMethods() as $method) { + $this->assertSame(in_array($method, $methodsExpectedTrue), $path->{$method}()); + } + } + + public function statusConditionsProvider(): array + { + return [ + '_A' => [ + 'status' => ' A', + 'methodsExpectedTrue' => ['isNotUpdated', 'isAddedInWorkingTree'], + ], + '_M' => [ + 'status' => ' M', + 'methodsExpectedTrue' => ['isNotUpdated', 'hasWorkingTreeChangedSinceIndex'], + ], + '_D' => [ + 'status' => ' D', + 'methodsExpectedTrue' => ['isNotUpdated', 'isDeletedInWorkingTree'], + ], + 'M_' => [ + 'status' => 'M ', + 'methodsExpectedTrue' => ['isUpdatedInIndex', 'doesIndexMatchWorkingTree'], + ], + 'MM' => [ + 'status' => 'MM', + 'methodsExpectedTrue' => ['isUpdatedInIndex', 'hasWorkingTreeChangedSinceIndex'], + ], + 'MD' => [ + 'status' => 'MD', + 'methodsExpectedTrue' => ['isUpdatedInIndex', 'isDeletedInWorkingTree'], + ], + 'A_' => [ + 'status' => 'A ', + 'methodsExpectedTrue' => ['isAddedToIndex', 'doesIndexMatchWorkingTree'], + ], + 'AM' => [ + 'status' => 'AM', + 'methodsExpectedTrue' => ['isAddedToIndex', 'hasWorkingTreeChangedSinceIndex'], + ], + 'AD' => [ + 'status' => 'AD', + 'methodsExpectedTrue' => ['isAddedToIndex', 'isDeletedInWorkingTree'], + ], + 'D_' => [ + 'status' => 'D ', + 'methodsExpectedTrue' => ['isDeletedFromIndex', 'doesIndexMatchWorkingTree'], + ], + 'R_' => [ + 'status' => 'R ', + 'methodsExpectedTrue' => ['isRenamedInIndex', 'doesIndexMatchWorkingTree'], + ], + 'RM' => [ + 'status' => 'RM', + 'methodsExpectedTrue' => ['isRenamedInIndex', 'hasWorkingTreeChangedSinceIndex'], + ], + 'RD' => [ + 'status' => 'RD', + 'methodsExpectedTrue' => ['isRenamedInIndex', 'isDeletedInWorkingTree'], + ], + 'C_' => [ + 'status' => 'C ', + 'methodsExpectedTrue' => ['isCopiedInIndex', 'doesIndexMatchWorkingTree'], + ], + 'CM' => [ + 'status' => 'CM', + 'methodsExpectedTrue' => ['isCopiedInIndex', 'hasWorkingTreeChangedSinceIndex'], + ], + 'CD' => [ + 'status' => 'CD', + 'methodsExpectedTrue' => ['isCopiedInIndex', 'isDeletedInWorkingTree'], + ], + '_R' => [ + 'status' => ' R', + 'methodsExpectedTrue' => ['isNotUpdated', 'isRenamedInWorkingTree'], + ], + 'DR' => [ + 'status' => 'DR', + 'methodsExpectedTrue' => ['isDeletedFromIndex', 'isRenamedInWorkingTree'], + ], + '_C' => [ + 'status' => ' C', + 'methodsExpectedTrue' => ['isNotUpdated', 'isCopiedInWorkingTree'], + ], + 'DC' => [ + 'status' => 'DC', + 'methodsExpectedTrue' => ['isDeletedFromIndex', 'isCopiedInWorkingTree'], + ], + 'DD' => [ + 'status' => 'DD', + 'methodsExpectedTrue' => ['isUnmerged', 'areBothDeleted'], + ], + 'AA' => [ + 'status' => 'AA', + 'methodsExpectedTrue' => ['isUnmerged', 'areBothAdded'], + ], + 'UU' => [ + 'status' => 'UU', + 'methodsExpectedTrue' => ['isUnmerged', 'areBothModified'], + ], + 'AU' => [ + 'status' => 'AU', + 'methodsExpectedTrue' => ['isUnmerged', 'isAddedByUs'], + ], + 'DU' => [ + 'status' => 'DU', + 'methodsExpectedTrue' => ['isUnmerged', 'isDeletedByUs'], + ], + 'UA' => [ + 'status' => 'UA', + 'methodsExpectedTrue' => ['isUnmerged', 'isAddedByThem'], + ], + 'UD' => [ + 'status' => 'UD', + 'methodsExpectedTrue' => ['isUnmerged', 'isDeletedByThem'], + ], + '??' => [ + 'status' => '??', + 'methodsExpectedTrue' => ['isUntracked'], + ], + '!!' => [ + 'status' => '!!', + 'methodsExpectedTrue' => ['isIgnored'], + ], + ]; + } + + private function getBooleanMethods(): array + { + $booleanMethods = []; + + $pathReflected = new ReflectionClass(Path::class); + $pathMethods = $pathReflected->getMethods(ReflectionMethod::IS_PUBLIC); + + foreach ($pathMethods as $method) { + if ($this->isBooleanMethod($method->getName())) { + $booleanMethods[] = $method->getName(); + } + } + + return $booleanMethods; + } + + private function isBooleanMethod(string $name): bool + { + $prefixes = ['is', 'does', 'has', 'are']; + + foreach ($prefixes as $prefix) { + if (strpos($name, $prefix) === 0) { + return true; + } + } + + return false; + } +}