├── src ├── Exceptions │ ├── GitNotAvailable.php │ ├── TreeNotAvailable.php │ ├── GitHeadNotAvailable.php │ ├── InvalidGlobPattern.php │ ├── NoLicenseFilePresent.php │ ├── PresetNotAvailable.php │ ├── InvalidGlobPatternFile.php │ ├── GitArchiveNotValidatedYet.php │ ├── NonExistentGlobPatternFile.php │ └── GitattributesCreationFailed.php ├── Preset.php ├── Helpers │ ├── InputReader.php │ ├── PhpInputReader.php │ └── Str.php ├── Presets │ ├── RustPreset.php │ ├── GoPreset.php │ ├── PythonPreset.php │ ├── CommonPreset.php │ ├── JavaScriptPreset.php │ ├── PhpPreset.php │ └── Finder.php ├── Archive │ └── Validator.php ├── ErrorHandler.php ├── Tree.php ├── Commands │ ├── CreateCommand.php │ ├── UpdateCommand.php │ ├── TreeCommand.php │ ├── InitCommand.php │ ├── Concerns │ │ └── GeneratesGitattributesOptions.php │ └── ValidateCommand.php ├── GitattributesFileRepository.php ├── PhpConfigLoader.php ├── Archive.php ├── Glob.php └── Analyser.php ├── bin └── lean-package-validator └── composer.json /src/Exceptions/GitNotAvailable.php: -------------------------------------------------------------------------------- 1 | getCommonGlob(), [ 14 | 'benches/**', 15 | '.rustfmt.toml', 16 | '.clippy.toml', 17 | ])); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Presets/GoPreset.php: -------------------------------------------------------------------------------- 1 | getCommonGlob(), [ 14 | 'go.*', 15 | 'cmd/**', 16 | 'pkg/**', 17 | 'internal/**', 18 | '*.go', 19 | '*_test.go', 20 | ])); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Presets/PythonPreset.php: -------------------------------------------------------------------------------- 1 | getCommonGlob(), [ 14 | '*.py[cod]', 15 | 'setup.*', 16 | 'requirements*.txt', 17 | 'Pipfile', 18 | 'Pipfile.lock', 19 | ])); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Presets/CommonPreset.php: -------------------------------------------------------------------------------- 1 | getCommonGlob(), [ 14 | 'package.json', 15 | 'package-lock.json', 16 | 'yarn.lock', 17 | 'pnpm-lock.yaml', 18 | 'bun.lockb', 19 | '__tests__/**', 20 | '*.{test,spec}.{js,ts,jsx,tsx}', 21 | 'tsconfig.json', 22 | 'tsconfig.*.json', 23 | '.eslintrc*', 24 | '.prettierrc*', 25 | '.babelrc*', 26 | 'vite.config.*', 27 | 'webpack.config.*', 28 | 'rollup.config.*', 29 | 'jest.config.*', 30 | ])); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Presets/PhpPreset.php: -------------------------------------------------------------------------------- 1 | getCommonGlob(), [ 14 | '*.lock', 15 | 'phpunit*', 16 | 'appveyor.yml', 17 | 'box.json', 18 | 'composer-dependency-analyser*', 19 | 'collision-detector*', 20 | 'captainhook.json', 21 | 'peck.json', 22 | 'infection*', 23 | 'phpstan*', 24 | 'sonar*', 25 | 'rector*', 26 | 'phpkg.con*', 27 | 'package*', 28 | 'pint.{json,php}', 29 | 'renovate.json', 30 | '*debugbar.json', 31 | 'phpinsights*', 32 | 'ecs*', 33 | 'RMT', 34 | '{{M,m}ake,{B,b}ox,{V,v}agrant,{P,p}hulp}file' 35 | ])); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /bin/lean-package-validator: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 3 | composer install' . PHP_EOL); 21 | exit(1); 22 | } 23 | 24 | \define('WORKING_DIRECTORY', \getcwd()); 25 | \define('VERSION', '5.2.3'); 26 | 27 | use Stolt\LeanPackage\Commands\CreateCommand; 28 | use Stolt\LeanPackage\Commands\InitCommand; 29 | use Stolt\LeanPackage\Commands\TreeCommand; 30 | use Stolt\LeanPackage\Commands\UpdateCommand; 31 | use Stolt\LeanPackage\Commands\ValidateCommand; 32 | use Stolt\LeanPackage\Analyser; 33 | use Stolt\LeanPackage\Archive; 34 | use Stolt\LeanPackage\Archive\Validator; 35 | use Stolt\LeanPackage\GitattributesFileRepository; 36 | use Stolt\LeanPackage\Helpers\PhpInputReader; 37 | use Stolt\LeanPackage\Presets\Finder; 38 | use Stolt\LeanPackage\Presets\PhpPreset; 39 | use Stolt\LeanPackage\Tree; 40 | use Symfony\Component\Console\Application; 41 | 42 | $finder = new Finder(new PhpPreset()); 43 | $archive = new Archive(WORKING_DIRECTORY); 44 | $analyser = new Analyser($finder); 45 | 46 | $initCommand = new InitCommand( 47 | $analyser 48 | ); 49 | $validateCommand = new ValidateCommand( 50 | $analyser, 51 | new Validator($archive), 52 | new PhpInputReader() 53 | ); 54 | $createCommand = new CreateCommand($analyser, new GItattributesFileRepository($analyser)); 55 | $updateCommand = new UpdateCommand($analyser, new GItattributesFileRepository($analyser)); 56 | $treeCommand = new TreeCommand(new Tree(new Archive(WORKING_DIRECTORY,'tree-temp'))); 57 | 58 | $application = new Application('Lean package validator', VERSION); 59 | $application->addCommands( 60 | [$initCommand, $validateCommand, $createCommand, $updateCommand, $treeCommand] 61 | ); 62 | $application->run(); 63 | -------------------------------------------------------------------------------- /src/Presets/Finder.php: -------------------------------------------------------------------------------- 1 | defaultPreset = $defaultPreset; 19 | } 20 | 21 | /** 22 | * @return array 23 | */ 24 | public function getAvailablePresets(): array 25 | { 26 | $dir = new \DirectoryIterator(\dirname(__FILE__)); 27 | $availablePresets = []; 28 | 29 | foreach ($dir as $fileinfo) { 30 | if (!$fileinfo->isDot()) { 31 | $presetsParts = \explode(self::PRESET_SUFFIX, $fileinfo->getBasename()); 32 | if (\count($presetsParts) == 2) { 33 | if ($presetsParts[0] === 'Common') { 34 | continue; 35 | } 36 | $availablePresets[] = $presetsParts[0]; 37 | } 38 | } 39 | } 40 | 41 | return $availablePresets; 42 | } 43 | 44 | /** 45 | * @param string $name 46 | * @throws PresetNotAvailable 47 | * @return array 48 | */ 49 | public function getPresetGlobByLanguageName(string $name): array 50 | { 51 | $preset = $this->getPresetByLanguageName($name); 52 | 53 | return $preset->getPresetGlob(); 54 | } 55 | 56 | /** 57 | * @param string $name 58 | * @throws PresetNotAvailable 59 | * @return Preset 60 | */ 61 | public function getPresetByLanguageName(string $name): Preset 62 | { 63 | $name = \ucfirst(\strtolower($name)); 64 | 65 | if (!\in_array($name, $this->getAvailablePresets())) { 66 | $message = \sprintf('Preset for %s not available. Maybe contribute it?.', $name); 67 | throw new PresetNotAvailable($message); 68 | } 69 | 70 | $presetClassName = \sprintf('Stolt\LeanPackage\Presets\%sPreset', $name); 71 | 72 | return new $presetClassName(); 73 | } 74 | 75 | /** 76 | * Returns the default Preset glob array 77 | * 78 | * @return array 79 | */ 80 | public function getDefaultPreset(): array 81 | { 82 | return $this->defaultPreset->getPresetGlob(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Archive/Validator.php: -------------------------------------------------------------------------------- 1 | archive = $archive; 31 | } 32 | 33 | /** 34 | * Set if license file presence should be validated. 35 | * 36 | * @return Validator 37 | */ 38 | public function shouldHaveLicenseFile(): Validator 39 | { 40 | $this->archive->shouldHaveLicenseFile(); 41 | 42 | return $this; 43 | } 44 | 45 | /** 46 | * Accessor for injected archive instance. 47 | * 48 | * @return Archive 49 | */ 50 | public function getArchive(): Archive 51 | { 52 | return $this->archive; 53 | } 54 | 55 | /** 56 | * Validate archive against unexpected artifacts. 57 | * 58 | * @param array $unexpectedArtifacts Artifacts not expected in archive. 59 | * 60 | * @throws GitNotAvailable 61 | * @throws GitHeadNotAvailable 62 | * @throws GitNotAvailable|NoLicenseFilePresent 63 | * @throws GitHeadNotAvailable 64 | * @return boolean 65 | */ 66 | public function validate(array $unexpectedArtifacts): bool 67 | { 68 | $foundUnexpectedArtifacts = $this->archive->getUnexpectedArchiveArtifacts( 69 | $unexpectedArtifacts 70 | ); 71 | $this->ranValidate = true; 72 | 73 | if ($foundUnexpectedArtifacts !== []) { 74 | return false; 75 | } 76 | 77 | return true; 78 | } 79 | 80 | /** 81 | * Accessor for found unexpected archive artifacts. 82 | * 83 | * @throws GitArchiveNotValidatedYet 84 | * 85 | * @return array 86 | */ 87 | public function getFoundUnexpectedArchiveArtifacts(): array 88 | { 89 | if ($this->ranValidate === false) { 90 | $message = 'Git archive ' . $this->archive->getFilename() 91 | . ' not validated. Run validate first.'; 92 | throw new GitArchiveNotValidatedYet($message); 93 | } 94 | 95 | return $this->archive->getFoundUnexpectedArtifacts(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | protected static $stack = []; 26 | 27 | /** 28 | * Check if this error handler is active 29 | * 30 | * @return bool 31 | */ 32 | public static function started() 33 | { 34 | return (bool) static::getNestedLevel(); 35 | } 36 | 37 | /** 38 | * Get the current nested level 39 | * 40 | * @return int 41 | */ 42 | public static function getNestedLevel() 43 | { 44 | return \count(static::$stack); 45 | } 46 | 47 | /** 48 | * Starting the error handler 49 | * 50 | * @param int $errorLevel 51 | * @return void 52 | */ 53 | public static function start($errorLevel = E_WARNING) 54 | { 55 | if (! static::$stack) { 56 | \set_error_handler([static::class, 'addError'], $errorLevel); 57 | } 58 | 59 | static::$stack[] = null; 60 | } 61 | 62 | /** 63 | * Stopping the error handler 64 | * 65 | * @param bool $throw Throw the ErrorException if any 66 | * @throws ErrorException If an error has been caught and $throw is true. 67 | * @return null|ErrorException 68 | */ 69 | public static function stop($throw = false) 70 | { 71 | $errorException = null; 72 | 73 | if (static::$stack) { 74 | $errorException = \array_pop(static::$stack); 75 | 76 | if (! static::$stack) { 77 | \restore_error_handler(); 78 | } 79 | 80 | if ($errorException && $throw) { 81 | throw $errorException; 82 | } 83 | } 84 | 85 | return $errorException; 86 | } 87 | 88 | /** 89 | * Stop all active handler 90 | * 91 | * @return void 92 | */ 93 | public static function clean() 94 | { 95 | if (static::$stack) { 96 | \restore_error_handler(); 97 | } 98 | 99 | static::$stack = []; 100 | } 101 | 102 | /** 103 | * Add an error to the stack 104 | * 105 | * @param int $errno 106 | * @param string $errstr 107 | * @param string $errfile 108 | * @param int $errline 109 | * @return void 110 | */ 111 | public static function addError($errno, $errstr = '', $errfile = '', $errline = 0) 112 | { 113 | $stack = &static::$stack[\count(static::$stack) - 1]; 114 | $stack = new ErrorException($errstr, 0, $errno, $errfile, $errline, $stack); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Tree.php: -------------------------------------------------------------------------------- 1 | archive = $archive; 20 | 21 | if (!$this->archive->isGitCommandAvailable()) { 22 | throw new GitNotAvailable(); 23 | } 24 | } 25 | 26 | public function getTreeForSrc(string $directory): string 27 | { 28 | return $this->getTree($directory); 29 | } 30 | 31 | /** 32 | * @throws GitHeadNotAvailable 33 | * @throws GitNotAvailable 34 | */ 35 | public function getTreeForDistPackage(string $directory): string 36 | { 37 | \chdir($directory); 38 | 39 | $this->archive->createArchive(); 40 | $temporaryDirectory = \sys_get_temp_dir() . '/dist-release'; 41 | 42 | if (!\file_exists($temporaryDirectory)) { 43 | \mkdir($temporaryDirectory); 44 | } 45 | 46 | $command = 'tar -xf ' . \escapeshellarg($this->archive->getFilename()) . ' --directory ' . $temporaryDirectory . ' 2>&1'; 47 | 48 | \exec($command); 49 | 50 | $distReleaseTree = $this->getTree($temporaryDirectory); 51 | 52 | 53 | $this->archive->removeArchive(); 54 | $this->removeDirectory($temporaryDirectory); 55 | 56 | return $distReleaseTree; 57 | } 58 | 59 | private function getTree(string $directory): string 60 | { 61 | $finder = new Finder(); 62 | $finder->in($directory)->ignoreVCSIgnored(true) 63 | ->ignoreDotFiles(false)->depth(0)->sortByName()->sortByType(); 64 | 65 | $tree[] = '.'; 66 | 67 | $index = 0; 68 | $directoryCount = 0; 69 | $fileCount = 0; 70 | 71 | foreach ($finder as $file) { 72 | $index++; 73 | $filename = $file->getFilename(); 74 | if ($file->isDir()) { 75 | $filename = $file->getFilename() . '/'; 76 | $directoryCount++; 77 | } 78 | 79 | if ($file->isFile()) { 80 | $fileCount++; 81 | } 82 | 83 | if ($index < $finder->count()) { 84 | $tree[] = '├── ' . $filename; 85 | } else { 86 | $tree[] = '└── ' . $filename; 87 | } 88 | } 89 | 90 | $tree[] = PHP_EOL; 91 | $tree[] = \sprintf( 92 | '%d %s, %d %s', 93 | $directoryCount, 94 | $directoryCount > 1 ? 'directories' : 'directory', 95 | $fileCount, 96 | $fileCount > 1 ? 'files': 'file' 97 | ); 98 | $tree[] = PHP_EOL; 99 | 100 | return \implode(PHP_EOL, $tree); 101 | } 102 | 103 | protected function removeDirectory(string $directory): void 104 | { 105 | $files = new \RecursiveIteratorIterator( 106 | new \RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), 107 | \RecursiveIteratorIterator::CHILD_FIRST 108 | ); 109 | 110 | /** @var \SplFileInfo $fileinfo */ 111 | foreach ($files as $fileinfo) { 112 | if ($fileinfo->isDir()) { 113 | @\rmdir($fileinfo->getRealPath()); 114 | continue; 115 | } 116 | @\unlink($fileinfo->getRealPath()); 117 | } 118 | 119 | @\rmdir($directory); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stolt/lean-package-validator", 3 | "description": "Library and CLI for validating if a project or package has and will have lean releases.", 4 | "keywords": ["lpv", "project", "package", "release", "lean", "gitattributes" , "dist", "validation", "cli", "dev"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Raphael Stolt", 9 | "email": "raphael.stolt@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.1", 14 | "sebastian/diff": "^7.0.0||^6.0.1||^5.0||^4.0.3", 15 | "symfony/console": "^7.2.1||^v5.4.8", 16 | "symfony/finder": "^7.2.1||^v5.4.8" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "Stolt\\LeanPackage\\": "src/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "Stolt\\LeanPackage\\Tests\\": "tests/" 26 | } 27 | }, 28 | "type": "library", 29 | "minimum-stability": "stable", 30 | "bin": ["bin/lean-package-validator"], 31 | "scripts-descriptions": { 32 | "lpv:test": "Runs all tests.", 33 | "lpv:test-with-coverage": "Runs all tests and measures code coverage.", 34 | "lpv:cs-fix": "Fixes coding standard violations.", 35 | "lpv:cs-lint": "Checks for coding standard violations.", 36 | "lpv:configure-commit-template": "Configures a local commit message template.", 37 | "lpv:application-version-guard": "Checks that the application version matches the given Git tag.", 38 | "lpv:application-phar-version-guard": "Checks that the PHAR version matches the given Git tag.", 39 | "lpv:static-analyse": "Runs a static code analysis via PHPStan.", 40 | "lpv:dependency-analyse": "Runs a dependency analysis to find shadowed dependencies.", 41 | "lpv:validate-gitattributes": "Checks the leanness of this package.", 42 | "lpv:pre-commit-check": "Does a final (aggregated) check before committing." 43 | }, 44 | "scripts": { 45 | "lpv:test": "phpunit", 46 | "lpv:test-with-coverage": "export XDEBUG_MODE=coverage && phpunit --coverage-html coverage-reports", 47 | "lpv:cs-fix": "php-cs-fixer --allow-risky=yes fix . -vv || true", 48 | "lpv:cs-lint": "php-cs-fixer fix --diff --stop-on-violation --verbose --dry-run --allow-risky=yes", 49 | "lpv:configure-commit-template": "git config --add commit.template .gitmessage", 50 | "lpv:application-version-guard": "php bin/application-version --verify-tag-match=bin", 51 | "lpv:application-phar-version-guard": "php bin/application-version --verify-tag-match=phar", 52 | "lpv:static-analyse": "phpstan analyse --configuration phpstan.neon.dist", 53 | "lpv:validate-gitattributes": "bin/lean-package-validator validate", 54 | "lpv:spell-check": "./vendor/bin/peck", 55 | "lpv:dependency-analyse": "./vendor/bin/composer-dependency-analyser", 56 | "lpv:pre-commit-check": [ 57 | "@lpv:test", 58 | "@lpv:cs-lint", 59 | "@lpv:static-analyse", 60 | "@lpv:dependency-analyse", 61 | "@lpv:spell-check", 62 | "@lpv:application-version-guard" 63 | ] 64 | }, 65 | "config": { 66 | "preferred-install": "dist", 67 | "sort-packages": true, 68 | "optimize-autoloader": true 69 | }, 70 | "require-dev": { 71 | "friendsofphp/php-cs-fixer": "^3.70.1", 72 | "mockery/mockery": "^1.0", 73 | "peckphp/peck": "^0.1.2", 74 | "phlak/semver": "^4.1 || ^6.0", 75 | "php-mock/php-mock": "^2.6", 76 | "php-mock/php-mock-phpunit": "^2.7||^1.1", 77 | "phpstan/phpstan": "^2.1", 78 | "phpunit/phpunit": "^11.4.4||^10.5.25", 79 | "shipmonk/composer-dependency-analyser": "^1.8", 80 | "zenstruck/console-test": "^1.7" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Commands/CreateCommand.php: -------------------------------------------------------------------------------- 1 | addArgument( 40 | 'directory', 41 | InputArgument::OPTIONAL, 42 | 'The package directory to create the .gitattributes file in', 43 | \defined('WORKING_DIRECTORY') ? WORKING_DIRECTORY : \getcwd() 44 | )->setName(self::$defaultName)->setDescription(self::$defaultDescription); 45 | 46 | // Add common generation options 47 | $this->addGenerationOptions(function (...$args) { 48 | $this->getDefinition()->addOption(new InputOption(...$args)); 49 | }); 50 | $this->getDefinition()->addOption(new InputOption( 51 | 'dry-run', 52 | null, 53 | InputOption::VALUE_NONE, 54 | 'Do not write any files. Output the expected .gitattributes content' 55 | )); 56 | } 57 | 58 | protected function execute(InputInterface $input, OutputInterface $output): int 59 | { 60 | $directory = (string) $input->getArgument('directory') ?: \getcwd(); 61 | $this->analyser->setDirectory($directory); 62 | 63 | // Apply options that influence generation 64 | if (!$this->applyGenerationOptions($input, $output, $this->analyser)) { 65 | return self::FAILURE; 66 | } 67 | 68 | $gitattributesPath = $this->analyser->getGitattributesFilePath(); 69 | 70 | if (\file_exists($gitattributesPath) && $input->getOption('dry-run') !== true) { 71 | $output->writeln('A .gitattributes file already exists. Use the update command to modify it.'); 72 | 73 | return self::FAILURE; 74 | } 75 | 76 | $expected = $this->analyser->getExpectedGitattributesContent(); 77 | 78 | if ($expected === '') { 79 | $output->writeln('Unable to determine expected .gitattributes content for the given directory.'); 80 | 81 | return self::FAILURE; 82 | } 83 | 84 | // Support dry-run: print expected content and exit successfully without writing. 85 | if ($input->getOption('dry-run') === true) { 86 | $output->writeln($expected); 87 | 88 | return self::SUCCESS; 89 | } 90 | 91 | try { 92 | $this->repository->createGitattributesFile($expected); 93 | } catch (\Throwable $e) { 94 | $output->writeln('Creation of .gitattributes file failed.'); 95 | 96 | return self::FAILURE; 97 | } 98 | 99 | $directory = \realpath($directory); 100 | $output->writeln("A .gitattributes file has been created in {$directory}."); 101 | 102 | return self::SUCCESS; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Commands/UpdateCommand.php: -------------------------------------------------------------------------------- 1 | addArgument( 40 | 'directory', 41 | InputArgument::OPTIONAL, 42 | 'The package directory whose .gitattributes file should be updated', 43 | \defined('WORKING_DIRECTORY') ? WORKING_DIRECTORY : \getcwd() 44 | )->setName(self::$defaultName)->setDescription(self::$defaultDescription); 45 | 46 | // Add common generation options 47 | $this->addGenerationOptions(function (...$args) { 48 | $this->getDefinition()->addOption(new InputOption(...$args)); 49 | }); 50 | 51 | // Add dry-run option 52 | $this->getDefinition()->addOption(new InputOption( 53 | 'dry-run', 54 | null, 55 | InputOption::VALUE_NONE, 56 | 'Do not write any files. Output the expected .gitattributes content' 57 | )); 58 | } 59 | 60 | protected function execute(InputInterface $input, OutputInterface $output): int 61 | { 62 | $directory = (string) $input->getArgument('directory') ?: \getcwd(); 63 | $this->analyser->setDirectory($directory); 64 | 65 | // Apply options that influence generation 66 | if (!$this->applyGenerationOptions($input, $output, $this->analyser)) { 67 | return self::FAILURE; 68 | } 69 | 70 | $gitattributesPath = $this->analyser->getGitattributesFilePath(); 71 | 72 | if (!\file_exists($gitattributesPath) && $input->getOption('dry-run') !== true) { 73 | $output->writeln('No .gitattributes file found. Use the create command to create one first.'); 74 | 75 | return self::FAILURE; 76 | } 77 | 78 | $expected = $this->analyser->getExpectedGitattributesContent(); 79 | 80 | if ($expected === '') { 81 | $output->writeln('Unable to determine expected .gitattributes content for the given directory.'); 82 | 83 | return self::FAILURE; 84 | } 85 | 86 | // Support dry-run: print expected content and exit successfully without writing. 87 | if ($input->getOption('dry-run') === true) { 88 | $output->writeln($expected); 89 | 90 | return self::SUCCESS; 91 | } 92 | 93 | try { 94 | $this->repository->overwriteGitattributesFile($expected); 95 | } catch (\Throwable $e) { 96 | $output->writeln('Update of .gitattributes file failed.'); 97 | 98 | return self::FAILURE; 99 | } 100 | 101 | $directory = \realpath($directory); 102 | $output->writeln("The .gitattributes file in {$directory} has been updated."); 103 | 104 | return self::SUCCESS; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/GitattributesFileRepository.php: -------------------------------------------------------------------------------- 1 | analyser = $analyser; 19 | } 20 | 21 | /** 22 | * Create the gitattributes file. 23 | * 24 | * @param string $content The content of the gitattributes file 25 | * @param bool $withHeader Whether to prepend the generated header 26 | * @throws GitattributesCreationFailed 27 | * @return string 28 | * 29 | */ 30 | public function createGitattributesFile(string $content, bool $withHeader = true): string 31 | { 32 | // Ensure the generated or modified by header is present when requested 33 | if ($withHeader) { 34 | $content = $this->applyOverwriteHeaderPolicy($content); 35 | } 36 | 37 | $bytesWritten = file_put_contents( 38 | $this->analyser->getGitattributesFilePath(), 39 | $content 40 | ); 41 | 42 | if ($bytesWritten) { 43 | $content = 'Created a .gitattributes file with the shown content:' 44 | . PHP_EOL . '' . $content . ''; 45 | 46 | return PHP_EOL . PHP_EOL . $content; 47 | } 48 | 49 | $message = 'Creation of .gitattributes file failed.'; 50 | throw new GitattributesCreationFailed($message); 51 | } 52 | 53 | /** 54 | * Overwrite an existing gitattributes file. 55 | * 56 | * @param string $content The content of the gitattributes file 57 | * @throws GitattributesCreationFailed 58 | * @return string 59 | * 60 | */ 61 | public function overwriteGitattributesFile(string $content): string 62 | { 63 | // Apply header policy before writing (generated → partly modified). 64 | $content = $this->applyOverwriteHeaderPolicy($content); 65 | 66 | $bytesWritten = file_put_contents( 67 | $this->analyser->getGitattributesFilePath(), 68 | $content 69 | ); 70 | 71 | if ($bytesWritten) { 72 | $content = 'Overwrote it with the shown content:' 73 | . PHP_EOL . '' . $content . ''; 74 | 75 | return PHP_EOL . PHP_EOL . $content; 76 | } 77 | 78 | $message = 'Overwrite of .gitattributes file failed.'; 79 | throw new GitattributesCreationFailed($message); 80 | } 81 | 82 | /** 83 | * Prepare .gitattributes content for overwriting by adjusting the header. 84 | * If the present file contains the "generated by" header, replace it with 85 | * the "partly modified by" header in the given content-to-write. 86 | */ 87 | public function applyOverwriteHeaderPolicy(string $contentToWrite): string 88 | { 89 | $gitattributesPath = $this->analyser->getGitattributesFilePath(); 90 | 91 | if (!\is_file($gitattributesPath)) { 92 | return self::GENERATED_HEADER . PHP_EOL . PHP_EOL . $contentToWrite; 93 | } 94 | 95 | if (\str_contains($contentToWrite, self::GENERATED_HEADER)) { 96 | return \str_replace(self::GENERATED_HEADER, self::MODIFIED_HEADER, $contentToWrite); 97 | } 98 | 99 | if ((\str_contains($contentToWrite, self::MODIFIED_HEADER) === false) && (\str_contains($contentToWrite, self::GENERATED_HEADER) === false)) { 100 | return self::MODIFIED_HEADER . PHP_EOL . PHP_EOL . $contentToWrite; 101 | } 102 | 103 | return $contentToWrite; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/PhpConfigLoader.php: -------------------------------------------------------------------------------- 1 | 60 | */ 61 | public static function load(string $path): array 62 | { 63 | /** @var mixed $config */ 64 | $config = (static function (string $__path) { 65 | /** @noinspection PhpIncludeInspection */ 66 | return require $__path; 67 | })($path); 68 | 69 | if (!\is_array($config)) { 70 | throw new \UnexpectedValueException('The configuration file must return an array.'); 71 | } 72 | 73 | $allowed = [ 74 | 'directory', 75 | 'preset', 76 | 'glob-pattern', 77 | 'glob-pattern-file', 78 | 'stdin-input', 79 | 'diff', 80 | 'report-stale-export-ignores', 81 | 'enforce-strict-order', 82 | 'enforce-alignment', 83 | 'sort-from-directories-to-files', 84 | 'keep-license', 85 | 'keep-readme', 86 | 'keep-glob-pattern', 87 | 'align-export-ignores', 88 | ]; 89 | 90 | $unknown = \array_diff(\array_keys($config), $allowed); 91 | if ($unknown !== []) { 92 | throw new \UnexpectedValueException('Unknown configuration keys: ' . \implode(', ', $unknown)); 93 | } 94 | 95 | // Basic type validation 96 | $stringKeys = ['directory', 'preset', 'glob-pattern', 'glob-pattern-file', 'keep-glob-pattern']; 97 | foreach ($stringKeys as $key) { 98 | if (isset($config[$key]) && !\is_string($config[$key])) { 99 | throw new \UnexpectedValueException(\sprintf('Configuration "%s" must be a string.', $key)); 100 | } 101 | } 102 | 103 | $boolKeys = [ 104 | 'stdin-input', 105 | 'diff', 106 | 'report-stale-export-ignores', 107 | 'enforce-strict-order', 108 | 'enforce-alignment', 109 | 'sort-from-directories-to-files', 110 | 'keep-license', 111 | 'keep-readme', 112 | 'align-export-ignores', 113 | ]; 114 | foreach ($boolKeys as $key) { 115 | if (isset($config[$key]) && !\is_bool($config[$key])) { 116 | throw new \UnexpectedValueException(\sprintf('Configuration "%s" must be a boolean.', $key)); 117 | } 118 | } 119 | 120 | return $config; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Commands/TreeCommand.php: -------------------------------------------------------------------------------- 1 | tree = $tree; 26 | parent::__construct(); 27 | } 28 | 29 | /** 30 | * Command configuration. 31 | * 32 | * @return void 33 | */ 34 | protected function configure(): void 35 | { 36 | $this->directoryToOperateOn = WORKING_DIRECTORY; 37 | $this->setName('tree'); 38 | $description = 'Displays the source structure of a given ' 39 | . "project/micro-package repository or it's dist package"; 40 | $this->setDescription($description); 41 | 42 | $directoryDescription = 'The directory of a project/micro-package repository'; 43 | 44 | $this->addArgument( 45 | 'directory', 46 | InputArgument::OPTIONAL, 47 | $directoryDescription, 48 | $this->directoryToOperateOn 49 | ); 50 | 51 | $srcDescription = 'Show the flat src structure of the project/micro-package repository'; 52 | $distPackageDescription = 'Show the flat dist package structure of the project/micro-package'; 53 | 54 | $this->addOption('src', null, InputOption::VALUE_NONE, $srcDescription); 55 | $this->addOption('dist-package', null, InputOption::VALUE_NONE, $distPackageDescription); 56 | } 57 | 58 | protected function execute(InputInterface $input, OutputInterface $output): int 59 | { 60 | $this->directoryToOperateOn = (string) $input->getArgument('directory'); 61 | 62 | if (!\is_dir($this->directoryToOperateOn)) { 63 | $warning = "Warning: The provided directory " 64 | . "'$this->directoryToOperateOn' does not exist or is not a directory."; 65 | $outputContent = '' . $warning . ''; 66 | $output->writeln($outputContent); 67 | 68 | return Command::FAILURE; 69 | } 70 | 71 | $showSrcTree = $input->getOption('src'); 72 | 73 | if ($showSrcTree) { 74 | $verboseOutput = '+ Showing flat structure of package source.'; 75 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 76 | 77 | $output->writeln('Package: ' . $this->getPackageName() . ''); 78 | $output->write($this->tree->getTreeForSrc($this->directoryToOperateOn)); 79 | 80 | return Command::SUCCESS; 81 | } 82 | 83 | $verboseOutput = '+ Showing flat structure of dist package.'; 84 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 85 | 86 | try { 87 | $treeToDisplay = $this->tree->getTreeForDistPackage($this->directoryToOperateOn); 88 | 89 | $output->writeln('Package: ' . $this->getPackageName() . ''); 90 | $output->write($treeToDisplay); 91 | } catch (GitHeadNotAvailable $e) { 92 | $output->writeln('Directory ' . $this->directoryToOperateOn . ' has no Git Head.'); 93 | return Command::FAILURE; 94 | } 95 | 96 | return Command::SUCCESS; 97 | } 98 | 99 | protected function getPackageName(): string 100 | { 101 | if (!\file_exists($this->directoryToOperateOn . DIRECTORY_SEPARATOR . 'composer.json')) { 102 | return self::UNKNOWN_PACKAGE_NAME; 103 | } 104 | 105 | $composerContentAsJson = \json_decode( 106 | \file_get_contents($this->directoryToOperateOn . DIRECTORY_SEPARATOR . 'composer.json'), 107 | true 108 | ); 109 | 110 | if (!isset($composerContentAsJson['name'])) { 111 | return self::UNKNOWN_PACKAGE_NAME; 112 | } 113 | 114 | return \trim($composerContentAsJson['name']); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Archive.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 49 | $this->name = $name; 50 | if ('' === $name) { 51 | $this->name = \basename($directory); 52 | } 53 | $this->filename = $directory 54 | . DIRECTORY_SEPARATOR 55 | . $name . '.tar.gz'; 56 | } 57 | 58 | /** 59 | * Set if license file presence should be validated. 60 | * 61 | * @return Archive 62 | */ 63 | public function shouldHaveLicenseFile(): Archive 64 | { 65 | $this->shouldHaveLicenseFile = true; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Guard for license file presence validation. 72 | * 73 | * @return boolean 74 | */ 75 | public function validateLicenseFilePresence(): bool 76 | { 77 | return $this->shouldHaveLicenseFile === true; 78 | } 79 | 80 | /** 81 | * Accessor for the archive filename. 82 | * 83 | * @return string 84 | */ 85 | public function getFilename(): string 86 | { 87 | return $this->filename; 88 | } 89 | 90 | /** 91 | * Accessor for found unexpected artifacts. 92 | * 93 | * @return array 94 | */ 95 | public function getFoundUnexpectedArtifacts(): array 96 | { 97 | return $this->foundUnexpectedArtifacts; 98 | } 99 | 100 | /** 101 | * Has repository a HEAD. 102 | * 103 | * @throws GitNotAvailable 104 | * 105 | * @return boolean 106 | */ 107 | public function hasHead(): bool 108 | { 109 | if ($this->isGitCommandAvailable()) { 110 | \exec('git show-ref --head 2>&1', $output, $returnValue); 111 | return $returnValue === 0; 112 | } 113 | 114 | throw new GitNotAvailable('The Git command is not available.'); 115 | } 116 | 117 | /** 118 | * Create a Git archive from the current HEAD. 119 | * 120 | * @throws GitHeadNotAvailable|GitNotAvailable 121 | * @return boolean 122 | */ 123 | public function createArchive(): bool 124 | { 125 | if ($this->hasHead()) { 126 | $command = 'git archive -o ' . $this->getFilename() . ' HEAD 2>&1'; 127 | \exec($command, $output, $returnValue); 128 | 129 | return $returnValue === 0; 130 | } 131 | 132 | throw new GitHeadNotAvailable('No Git HEAD present to create an archive from.'); 133 | } 134 | 135 | /** 136 | * Is the Git command available? 137 | * 138 | * @param string $command The command to check availabilty of. Defaults to git. 139 | * 140 | * @return boolean 141 | */ 142 | public function isGitCommandAvailable($command = 'git'): bool 143 | { 144 | \exec('where ' . $command . ' 2>&1', $output, $returnValue); 145 | if ((new OsHelper())->isWindows() === false) { 146 | \exec('which ' . $command . ' 2>&1', $output, $returnValue); 147 | } 148 | 149 | return $returnValue === 0; 150 | } 151 | 152 | /** 153 | * Compare archive against unexpected artifacts. 154 | * 155 | * @param array $unexpectedArtifacts The unexpected artifacts. 156 | * @throws NoLicenseFilePresent 157 | * @return array 158 | */ 159 | public function compareArchive(array $unexpectedArtifacts): array 160 | { 161 | $foundUnexpectedArtifacts = []; 162 | $archive = new PharData($this->getFilename()); 163 | $hasLicenseFile = false; 164 | 165 | foreach ($archive as $archiveFile) { 166 | if ($archiveFile instanceof \SplFileInfo) { 167 | if ($archiveFile->isDir()) { 168 | $file = \basename($archiveFile) . '/'; 169 | if (\in_array($file, $unexpectedArtifacts)) { 170 | $foundUnexpectedArtifacts[] = $file; 171 | } 172 | continue; 173 | } 174 | 175 | $file = \basename($archiveFile); 176 | if ($this->validateLicenseFilePresence()) { 177 | if (\preg_match('/(License.*)/i', $file)) { 178 | $hasLicenseFile = true; 179 | } 180 | } 181 | 182 | if (\in_array($file, $unexpectedArtifacts)) { 183 | $foundUnexpectedArtifacts[] = $file; 184 | } 185 | } 186 | } 187 | 188 | if ($this->validateLicenseFilePresence() && $hasLicenseFile === false) { 189 | throw new NoLicenseFilePresent('No license file present in archive.'); 190 | } 191 | 192 | \sort($foundUnexpectedArtifacts, SORT_STRING | SORT_FLAG_CASE); 193 | 194 | return $foundUnexpectedArtifacts; 195 | } 196 | 197 | /** 198 | * Delegator for temporary archive creation and comparison against 199 | * a set of unexpected artifacts. 200 | * 201 | * @param array $unexpectedArtifacts The unexpected artifacts of the archive. 202 | * @throws GitHeadNotAvailable|NoLicenseFilePresent|GitNotAvailable 203 | * @return array 204 | */ 205 | public function getUnexpectedArchiveArtifacts(array $unexpectedArtifacts): array 206 | { 207 | $this->createArchive(); 208 | $this->foundUnexpectedArtifacts = $this->compareArchive($unexpectedArtifacts); 209 | $this->removeArchive(); 210 | 211 | return $this->foundUnexpectedArtifacts; 212 | } 213 | 214 | /** 215 | * Remove temporary Git archive. 216 | * 217 | * @return boolean 218 | */ 219 | public function removeArchive(): bool 220 | { 221 | if (\file_exists($this->getFilename())) { 222 | return \unlink($this->getFilename()); 223 | } 224 | 225 | return false; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/Commands/InitCommand.php: -------------------------------------------------------------------------------- 1 | analyser = $analyser; 38 | $this->finder = $analyser->getFinder(); 39 | 40 | parent::__construct(); 41 | } 42 | 43 | /** 44 | * Command configuration. 45 | * 46 | * @return void 47 | */ 48 | protected function configure(): void 49 | { 50 | $this->analyser->setDirectory(WORKING_DIRECTORY); 51 | $this->setName('init'); 52 | $description = 'Creates a default .lpv file in a given ' 53 | . 'project/micro-package repository'; 54 | $this->setDescription($description); 55 | 56 | $availablePresets = $this->formatAvailablePresetDefinitionsForDescription( 57 | $this->finder->getAvailablePresets() 58 | ); 59 | 60 | $directoryDescription = 'The directory of a project/micro-package repository'; 61 | $overwriteDescription = 'Overwrite existing default .lpv file file'; 62 | $presetDescription = 'The preset to use for the .lpv file. Available ones are ' . $availablePresets . '.'; 63 | 64 | $this->addArgument( 65 | 'directory', 66 | InputArgument::OPTIONAL, 67 | $directoryDescription, 68 | $this->analyser->getDirectory() 69 | ); 70 | $this->addOption('overwrite', 'o', InputOption::VALUE_NONE, $overwriteDescription); 71 | $this->addOption( 72 | 'preset', 73 | null, 74 | InputOption::VALUE_REQUIRED, 75 | $presetDescription, 76 | self::DEFAULT_PRESET 77 | ); 78 | $this->getDefinition()->addOption(new InputOption( 79 | 'dry-run', 80 | null, 81 | InputOption::VALUE_NONE, 82 | 'Do not write any files. Output the content that would be written' 83 | )); 84 | } 85 | 86 | /** 87 | * @param array $presets 88 | * @return string 89 | */ 90 | private function formatAvailablePresetDefinitionsForDescription(array $presets): string 91 | { 92 | $presets = \array_map(function ($preset) { 93 | return '' . $preset . ''; 94 | }, $presets); 95 | 96 | if (\count($presets) > 2) { 97 | $lastPreset = \array_pop($presets); 98 | return \implode(', ', $presets) . ', and ' . $lastPreset; 99 | } 100 | 101 | return $presets[0] . ' and ' . $presets[1]; 102 | } 103 | 104 | /** 105 | * Execute command. 106 | * 107 | * @param InputInterface $input 108 | * @param OutputInterface $output 109 | * 110 | * @throws PresetNotAvailable 111 | * @return integer 112 | */ 113 | protected function execute(InputInterface $input, OutputInterface $output): int 114 | { 115 | $directory = (string) $input->getArgument('directory'); 116 | $overwriteDefaultLpvFile = $input->getOption('overwrite'); 117 | $chosenPreset = (string) $input->getOption('preset'); 118 | $globPatternFromPreset = false; 119 | 120 | if ($directory !== WORKING_DIRECTORY) { 121 | try { 122 | $this->analyser->setDirectory($directory); 123 | } catch (\RuntimeException $e) { 124 | $warning = "Warning: The provided directory " 125 | . "'$directory' does not exist or is not a directory."; 126 | $outputContent = '' . $warning . ''; 127 | $output->writeln($outputContent); 128 | 129 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 130 | 131 | return Command::FAILURE; 132 | } 133 | } 134 | 135 | $defaultLpvFile = WORKING_DIRECTORY . DIRECTORY_SEPARATOR . '.lpv'; 136 | 137 | $verboseOutput = "+ Checking .lpv file existence in " . WORKING_DIRECTORY . "."; 138 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 139 | 140 | if (\file_exists($defaultLpvFile) && $overwriteDefaultLpvFile === false) { 141 | $warning = 'Warning: A default .lpv file already exists.'; 142 | $outputContent = '' . $warning . ''; 143 | $output->writeln($outputContent); 144 | 145 | return Command::FAILURE; 146 | } 147 | 148 | if ($chosenPreset && \in_array(\strtolower($chosenPreset), \array_map('strtolower', $this->finder->getAvailablePresets()))) { 149 | $verboseOutput = '+ Loading preset ' . $chosenPreset . '.'; 150 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 151 | $globPatternFromPreset = true; 152 | $defaultGlobPattern = $this->finder->getPresetGlobByLanguageName($chosenPreset); 153 | } else { 154 | $warning = 'Warning: Chosen preset ' . $chosenPreset . ' is not available. Maybe contribute it?.'; 155 | $outputContent = '' . $warning . ''; 156 | $output->writeln($outputContent); 157 | 158 | return Command::FAILURE; 159 | } 160 | 161 | $lpvFileContent = \implode("\n", $defaultGlobPattern); 162 | 163 | if ($input->getOption('dry-run') === true) { 164 | $output->writeln($lpvFileContent); 165 | 166 | return self::SUCCESS; 167 | } 168 | 169 | $bytesWritten = file_put_contents( 170 | $defaultLpvFile, 171 | $lpvFileContent 172 | ); 173 | 174 | $verboseOutput = '+ Writing default glob pattern to .lpv file in ' . WORKING_DIRECTORY . '.'; 175 | 176 | if ($globPatternFromPreset === true) { 177 | $verboseOutput = '+ Writing glob pattern for preset ' . $chosenPreset . ' to .lpv file in ' . WORKING_DIRECTORY . '.'; 178 | } 179 | 180 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 181 | 182 | if ($bytesWritten === false) { 183 | $warning = 'Warning: The creation of the default .lpv file failed.'; 184 | $outputContent = '' . $warning . ''; 185 | $output->writeln($outputContent); 186 | 187 | return Command::FAILURE; 188 | } 189 | 190 | $info = "Created default '$defaultLpvFile' file."; 191 | $output->writeln($info); 192 | 193 | return Command::SUCCESS; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Glob.php: -------------------------------------------------------------------------------- 1 | 0) { 83 | $excludes[] = \array_values($match)[0]; 84 | } 85 | } 86 | 87 | return $excludes; 88 | } 89 | 90 | /** 91 | * Use the glob function provided by the system. 92 | * 93 | * @param string $pattern 94 | * @param int $flags 95 | * @throws RuntimeException 96 | * @return array 97 | */ 98 | protected static function systemGlob($pattern, $flags) 99 | { 100 | if ($flags) { 101 | $flagMap = [ 102 | self::GLOB_MARK => GLOB_MARK, 103 | self::GLOB_NOSORT => GLOB_NOSORT, 104 | self::GLOB_NOCHECK => GLOB_NOCHECK, 105 | self::GLOB_NOESCAPE => GLOB_NOESCAPE, 106 | self::GLOB_BRACE => \defined('GLOB_BRACE') ? GLOB_BRACE : 0, 107 | self::GLOB_ONLYDIR => GLOB_ONLYDIR, 108 | self::GLOB_ERR => GLOB_ERR, 109 | ]; 110 | 111 | $globFlags = 0; 112 | 113 | foreach ($flagMap as $internalFlag => $globFlag) { 114 | if ($flags & $internalFlag) { 115 | $globFlags |= $globFlag; 116 | } 117 | } 118 | } else { 119 | $globFlags = 0; 120 | } 121 | 122 | ErrorHandler::start(); 123 | $res = \glob($pattern, $globFlags); 124 | $err = ErrorHandler::stop(); 125 | if ($res === false) { 126 | throw new RuntimeException("glob('{$pattern}', {$globFlags}) failed", 0, $err); 127 | } 128 | return $res; 129 | } 130 | 131 | /** 132 | * Expand braces manually, then use the system glob. 133 | * 134 | * @param string $pattern 135 | * @param int $flags 136 | * @throws RuntimeException 137 | * @return array 138 | */ 139 | protected static function fallbackGlob($pattern, $flags) 140 | { 141 | if (! self::flagsIsEqualTo($flags, self::GLOB_BRACE)) { 142 | return static::systemGlob($pattern, $flags); 143 | } 144 | 145 | $flags &= ~self::GLOB_BRACE; 146 | $length = \strlen($pattern); 147 | $paths = []; 148 | 149 | if ($flags & self::GLOB_NOESCAPE) { 150 | $begin = \strpos($pattern, '{'); 151 | } else { 152 | $begin = 0; 153 | 154 | while (true) { 155 | if ($begin === $length) { 156 | $begin = false; 157 | break; 158 | } elseif ($pattern[$begin] === '\\' && ($begin + 1) < $length) { 159 | $begin++; 160 | } elseif ($pattern[$begin] === '{') { 161 | break; 162 | } 163 | 164 | $begin++; 165 | } 166 | } 167 | 168 | if ($begin === false) { 169 | return static::systemGlob($pattern, $flags); 170 | } 171 | 172 | $next = static::nextBraceSub($pattern, $begin + 1, $flags); 173 | 174 | if ($next === null) { 175 | return static::systemGlob($pattern, $flags); 176 | } 177 | 178 | $rest = $next; 179 | 180 | while ($pattern[$rest] !== '}') { 181 | $rest = static::nextBraceSub($pattern, $rest + 1, $flags); 182 | 183 | if ($rest === null) { 184 | return static::systemGlob($pattern, $flags); 185 | } 186 | } 187 | 188 | $p = $begin + 1; 189 | 190 | while (true) { 191 | $subPattern = \substr($pattern, 0, $begin) 192 | . \substr($pattern, $p, $next - $p) 193 | . \substr($pattern, $rest + 1); 194 | 195 | $result = static::fallbackGlob($subPattern, $flags | self::GLOB_BRACE); 196 | 197 | if ($result) { 198 | $paths = \array_merge($paths, $result); 199 | } 200 | 201 | if ($pattern[$next] === '}') { 202 | break; 203 | } 204 | 205 | $p = $next + 1; 206 | $next = static::nextBraceSub($pattern, $p, $flags); 207 | } 208 | 209 | return \array_unique($paths); 210 | } 211 | 212 | /** 213 | * Find the end of the sub-pattern in a brace expression. 214 | * 215 | * @param string $pattern 216 | * @param int $begin 217 | * @param int $flags 218 | * @return int|null 219 | */ 220 | protected static function nextBraceSub($pattern, $begin, $flags) 221 | { 222 | $length = \strlen($pattern); 223 | $depth = 0; 224 | $current = $begin; 225 | 226 | while ($current < $length) { 227 | $flagsEqualsNoEscape = self::flagsIsEqualTo($flags, self::GLOB_NOESCAPE); 228 | 229 | if ($flagsEqualsNoEscape && $pattern[$current] === '\\') { 230 | if (++$current === $length) { 231 | break; 232 | } 233 | 234 | $current++; 235 | } else { 236 | if ( 237 | ($pattern[$current] === '}' && $depth-- === 0) 238 | || ($pattern[$current] === ',' && $depth === 0) 239 | ) { 240 | break; 241 | } elseif ($pattern[$current++] === '{') { 242 | $depth++; 243 | } 244 | } 245 | } 246 | 247 | return $current < $length ? $current : null; 248 | } 249 | 250 | /** @internal */ 251 | public static function flagsIsEqualTo(int $flags, int $otherFlags): bool 252 | { 253 | return (bool) ($flags & $otherFlags); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/Commands/Concerns/GeneratesGitattributesOptions.php: -------------------------------------------------------------------------------- 1 | getDefaultLpvFile()); 32 | $addOption('preset', null, InputOption::VALUE_OPTIONAL, 'Use the glob pattern of the given language preset', $this->defaultPreset); 33 | 34 | // Keep rules 35 | $addOption('keep-license', null, InputOption::VALUE_NONE, 'Do not export-ignore the license file'); 36 | $addOption('keep-readme', null, InputOption::VALUE_NONE, 'Do not export-ignore the README file'); 37 | $addOption('keep-glob-pattern', null, InputOption::VALUE_REQUIRED, 'Do not export-ignore matching glob pattern e.g. {LICENSE.*,README.*,docs*}'); 38 | 39 | // Layout/ordering 40 | $addOption('align-export-ignores', 'a', InputOption::VALUE_NONE, 'Align export-ignores on create or overwrite'); 41 | $addOption('sort-from-directories-to-files', 's', InputOption::VALUE_NONE, 'Sort export-ignores from directories to files'); 42 | $addOption('enforce-strict-order', null, InputOption::VALUE_NONE, 'Enforce strict order comparison (useful for consistency)'); 43 | } 44 | 45 | /** 46 | * Apply generation-related options to the analyser. 47 | */ 48 | protected function applyGenerationOptions(InputInterface $input, OutputInterface $output, Analyser $analyser): bool 49 | { 50 | $globPattern = $input->getOption('glob-pattern'); 51 | $globPatternFile = (string) $input->getOption('glob-pattern-file'); 52 | $chosenPreset = (string) $input->getOption('preset'); 53 | 54 | $keepLicense = (bool) $input->getOption('keep-license'); 55 | $keepReadme = (bool) $input->getOption('keep-readme'); 56 | $keepGlobPattern = (string) ($input->getOption('keep-glob-pattern') ?? ''); 57 | 58 | $alignExportIgnores = (bool) $input->getOption('align-export-ignores'); 59 | $sortFromDirectoriesToFiles = (bool) $input->getOption('sort-from-directories-to-files'); 60 | $enforceStrictOrderComparison = (bool) $input->getOption('enforce-strict-order'); 61 | 62 | // Order comparison (for consistency in generation/validation flow) 63 | if ($enforceStrictOrderComparison && $sortFromDirectoriesToFiles === false) { 64 | $output->writeln('+ Enforcing strict order comparison.', OutputInterface::VERBOSITY_VERBOSE); 65 | $analyser->enableStrictOrderComparison(); 66 | } 67 | 68 | if ($sortFromDirectoriesToFiles) { 69 | $output->writeln('+ Sorting from files to directories.', OutputInterface::VERBOSITY_VERBOSE); 70 | $analyser->sortFromDirectoriesToFiles(); 71 | } 72 | 73 | if ($keepLicense) { 74 | $output->writeln('+ Keeping the license file.', OutputInterface::VERBOSITY_VERBOSE); 75 | $analyser->keepLicense(); 76 | } 77 | 78 | if ($keepReadme) { 79 | $output->writeln('+ Keeping the README file.', OutputInterface::VERBOSITY_VERBOSE); 80 | $analyser->keepReadme(); 81 | } 82 | 83 | if ($keepGlobPattern !== '') { 84 | $output->writeln(\sprintf('+ Keeping files matching the glob pattern %s.', $keepGlobPattern), OutputInterface::VERBOSITY_VERBOSE); 85 | try { 86 | $analyser->setKeepGlobPattern($keepGlobPattern); 87 | } catch (InvalidGlobPattern $e) { 88 | $warning = "Warning: The provided glob pattern '{$keepGlobPattern}' is considered invalid."; 89 | $output->writeln('' . $warning . ''); 90 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 91 | 92 | return false; 93 | } 94 | } 95 | 96 | if ($alignExportIgnores) { 97 | $output->writeln('+ Aligning the export-ignores.', OutputInterface::VERBOSITY_VERBOSE); 98 | $analyser->alignExportIgnores(); 99 | } 100 | 101 | // Glob selection/override order: explicit pattern -> glob file -> preset 102 | if ($globPattern || $globPattern === '') { 103 | try { 104 | $output->writeln("+ Using glob pattern {$globPattern}.", OutputInterface::VERBOSITY_VERBOSE); 105 | $analyser->setGlobPattern((string) $globPattern); 106 | } catch (InvalidGlobPattern $e) { 107 | $warning = "Warning: The provided glob pattern '{$globPattern}' is considered invalid."; 108 | $output->writeln('' . $warning . ''); 109 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 110 | 111 | return false; 112 | } 113 | } elseif ($this->isGlobPatternFileSettable($globPatternFile)) { 114 | try { 115 | if ($this->isDefaultGlobPatternFilePresent()) { 116 | $analyser->setGlobPatternFromFile($globPatternFile); 117 | } 118 | if ($globPatternFile) { 119 | $globPatternFileInfo = new SplFileInfo($globPatternFile); 120 | $output->writeln('+ Using ' . $globPatternFileInfo->getBasename() . ' file as glob pattern input.', OutputInterface::VERBOSITY_VERBOSE); 121 | 122 | $analyser->setGlobPatternFromFile($globPatternFile); 123 | } 124 | } catch (NonExistentGlobPatternFile $e) { 125 | $warning = "Warning: The provided glob pattern file '{$globPatternFile}' doesn't exist."; 126 | $output->writeln('' . $warning . ''); 127 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 128 | 129 | return false; 130 | } catch (InvalidGlobPatternFile $e) { 131 | $warning = "Warning: The provided glob pattern file '{$globPatternFile}' is considered invalid."; 132 | $output->writeln('' . $warning . ''); 133 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 134 | 135 | return false; 136 | } 137 | } elseif ($chosenPreset) { 138 | try { 139 | $output->writeln('+ Using the ' . $chosenPreset . ' language preset.', OutputInterface::VERBOSITY_VERBOSE); 140 | $analyser->setGlobPatternFromPreset($chosenPreset); 141 | } catch (\Stolt\LeanPackage\Exceptions\PresetNotAvailable $e) { 142 | $warning = 'Warning: The chosen language preset ' . $chosenPreset . ' is not available. Maybe contribute it?.'; 143 | $output->writeln('' . $warning . ''); 144 | 145 | return false; 146 | } 147 | } 148 | 149 | return true; 150 | } 151 | 152 | // Minimal copies of helper checks used in ValidateCommand 153 | protected function isGlobPatternFileSettable(string $file): bool 154 | { 155 | if ($this->isGlobPatternFileProvided($file)) { 156 | return true; 157 | } 158 | 159 | return $this->isDefaultGlobPatternFilePresent(); 160 | } 161 | 162 | protected function isGlobPatternFileProvided(string $file): bool 163 | { 164 | return $file !== $this->getDefaultLpvFile(); 165 | } 166 | 167 | protected function isDefaultGlobPatternFilePresent(): bool 168 | { 169 | return \file_exists($this->getDefaultLpvFile()); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Commands/ValidateCommand.php: -------------------------------------------------------------------------------- 1 | analyser = $analyser; 74 | $this->archiveValidator = $archiveValidator; 75 | $this->gitattributesFileRepository = new GitattributesFileRepository($this->analyser); 76 | $this->inputReader = $inputReader; 77 | 78 | parent::__construct(); 79 | } 80 | 81 | /** 82 | * Command configuration. 83 | * 84 | * @return void 85 | */ 86 | protected function configure(): void 87 | { 88 | $this->analyser->setDirectory(WORKING_DIRECTORY); 89 | $this->setName('validate'); 90 | $description = 'Validates the .gitattributes file of a given ' 91 | . 'project/micro-package repository'; 92 | $this->setDescription($description); 93 | 94 | $directoryDescription = 'The directory of a project/micro-package repository'; 95 | 96 | $this->addArgument( 97 | 'directory', 98 | InputArgument::OPTIONAL, 99 | $directoryDescription, 100 | $this->analyser->getDirectory() 101 | ); 102 | 103 | $createDescription = 'Create a .gitattributes file if not present'; 104 | $enforceStrictOrderDescription = 'Enforce a strict order comparison of ' 105 | . 'export-ignores in the .gitattributes file'; 106 | $enforceExportIgnoreAlignmentDescription = 'Enforce a strict alignment of ' 107 | . 'export-ignores in the .gitattributes file'; 108 | $overwriteDescription = 'Overwrite existing .gitattributes file ' 109 | . 'with missing export-ignores'; 110 | $validateArchiveDescription = 'Validate Git archive against current HEAD'; 111 | $omitHeaderDescription = 'Omit adding a header to created or modified .gitattributes file'; 112 | $diffDescription = 'Show difference between expected and actual .gitattributes content'; 113 | $reportStaleExportIgnoresDescription = 'Filter stale export-ignores referencing non existent artifacts. Requires --diff option to be set'; 114 | 115 | $exampleGlobPattern = '{.*,*.md}'; 116 | $globPatternDescription = 'Use this glob pattern e.g. ' 117 | . $exampleGlobPattern . ' to match artifacts which should be ' 118 | . 'export-ignored'; 119 | $globPatternFileDescription = 'Use this file with glob patterns ' 120 | . 'to match artifacts which should be export-ignored'; 121 | $presetDescription = 'Use the glob pattern of the given language preset'; 122 | 123 | $keepLicenseDescription = 'Do not export-ignore the license file'; 124 | $keepReadmeDescription = 'Do not export-ignore the README file'; 125 | $keepGlobPatternDescription = 'Do not export-ignore matching glob pattern e.g. {LICENSE.*,README.*,docs*}'; 126 | $sortDescription = 'Sort from directories to files'; 127 | 128 | $alignExportIgnoresDescription = 'Align export-ignores on create or overwrite'; 129 | $stdinInputDescription = "Read .gitattributes content from standard input"; 130 | 131 | $this->addOption('stdin-input', null, InputOption::VALUE_NONE, $stdinInputDescription); 132 | $this->addOption('create', 'c', InputOption::VALUE_NONE, $createDescription); 133 | $this->addOption( 134 | 'enforce-strict-order', 135 | null, 136 | InputOption::VALUE_NONE, 137 | $enforceStrictOrderDescription 138 | ); 139 | $this->addOption( 140 | 'enforce-alignment', 141 | null, 142 | InputOption::VALUE_NONE, 143 | $enforceExportIgnoreAlignmentDescription 144 | ); 145 | 146 | $this->addOption('overwrite', 'o', InputOption::VALUE_NONE, $overwriteDescription); 147 | $this->addOption( 148 | 'validate-git-archive', 149 | null, 150 | InputOption::VALUE_NONE, 151 | $validateArchiveDescription 152 | ); 153 | $this->addOption( 154 | 'glob-pattern', 155 | null, 156 | InputOption::VALUE_REQUIRED, 157 | $globPatternDescription 158 | ); 159 | $this->addOption( 160 | 'glob-pattern-file', 161 | null, 162 | InputOption::VALUE_OPTIONAL, 163 | $globPatternFileDescription, 164 | $this->defaultLpvFile 165 | ); 166 | $this->addOption( 167 | 'preset', 168 | null, 169 | InputOption::VALUE_OPTIONAL, 170 | $presetDescription, 171 | $this->defaultPreset 172 | ); 173 | $this->addOption( 174 | 'keep-license', 175 | null, 176 | InputOption::VALUE_NONE, 177 | $keepLicenseDescription 178 | ); 179 | $this->addOption( 180 | 'keep-readme', 181 | null, 182 | InputOption::VALUE_NONE, 183 | $keepReadmeDescription 184 | ); 185 | $this->addOption( 186 | 'keep-glob-pattern', 187 | null, 188 | InputOption::VALUE_NONE, 189 | $keepGlobPatternDescription 190 | ); 191 | $this->addOption( 192 | 'align-export-ignores', 193 | 'a', 194 | InputOption::VALUE_NONE, 195 | $alignExportIgnoresDescription 196 | ); 197 | $this->addOption( 198 | 'sort-from-directories-to-files', 199 | 's', 200 | InputOption::VALUE_NONE, 201 | $sortDescription 202 | ); 203 | $this->addOption( 204 | 'omit-header', 205 | null, 206 | InputOption::VALUE_NONE, 207 | $omitHeaderDescription 208 | ); 209 | $this->addOption( 210 | 'diff', 211 | null, 212 | InputOption::VALUE_NONE, 213 | $diffDescription 214 | ); 215 | $this->addOption( 216 | 'report-stale-export-ignores', 217 | null, 218 | InputOption::VALUE_NONE, 219 | $reportStaleExportIgnoresDescription 220 | ); 221 | } 222 | 223 | /** 224 | * Execute command. 225 | * 226 | * @param InputInterface $input 227 | * @param OutputInterface $output 228 | * 229 | * @throws GitArchiveNotValidatedYet 230 | * @throws GitHeadNotAvailable 231 | * @throws GitNotAvailable 232 | * @return integer 233 | */ 234 | protected function execute(InputInterface $input, OutputInterface $output): int 235 | { 236 | $directory = (string) $input->getArgument('directory'); 237 | $chosenPreset = (string) $input->getOption('preset'); 238 | 239 | if ($directory !== WORKING_DIRECTORY) { 240 | try { 241 | $this->analyser->setDirectory($directory); 242 | } catch (\RuntimeException $e) { 243 | $warning = "Warning: The provided directory " 244 | . "'$directory' does not exist or is not a directory."; 245 | $outputContent = '' . $warning . ''; 246 | $output->writeln($outputContent); 247 | 248 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 249 | 250 | return Command::FAILURE; 251 | } 252 | } 253 | 254 | $stdinInput = $input->getOption('stdin-input'); 255 | 256 | if ($stdinInput !== false) { 257 | $stdinInputContents = $this->inputReader->get(); 258 | 259 | if (!\strpos($stdinInputContents, 'export-ignore')) { 260 | $warning = "Warning: The provided input stream seems to be no .gitattributes content."; 261 | $outputContent = '' . $warning . ''; 262 | $output->writeln($outputContent); 263 | 264 | return Command::FAILURE; 265 | } 266 | 267 | // Apply generation-related options (e.g. --enforce-strict-order, --enforce-alignment, keep-*) 268 | if (!$this->applyGenerationOptions($input, $output, $this->analyser)) { 269 | return Command::FAILURE; 270 | } 271 | 272 | $verboseOutput = '+ Validating .gitattributes content from STDIN.'; 273 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 274 | 275 | if ($this->analyser->hasCompleteExportIgnoresFromString($stdinInputContents)) { 276 | $info = 'The provided .gitattributes content is considered valid.'; 277 | $output->writeln($info); 278 | 279 | return Command::SUCCESS; 280 | } 281 | 282 | $outputContent = 'The provided .gitattributes file is considered invalid.'; 283 | $output->writeln($outputContent); 284 | 285 | return Command::FAILURE; 286 | } 287 | 288 | $verboseOutput = '+ Scanning directory ' . $directory . '.'; 289 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 290 | 291 | // Print deprecation notices for legacy options but do NOT change the exit code. 292 | if ($input->hasOption('create') && (bool) $input->getOption('create')) { 293 | $output->writeln('The --create option is deprecated. Please use the dedicated create command.'); 294 | } 295 | if ($input->hasOption('overwrite') && (bool) $input->getOption('overwrite')) { 296 | $output->writeln('The --overwrite option is deprecated. Please use the dedicated update command.'); 297 | } 298 | 299 | // Apply shared generation-related options via the trait (glob/preset/keep/alignment/order) 300 | if (!$this->applyGenerationOptions($input, $output, $this->analyser)) { 301 | return Command::FAILURE; 302 | } 303 | 304 | $createGitattributesFile = $input->getOption('create'); 305 | $overwriteGitattributesFile = $input->getOption('overwrite'); 306 | $validateArchive = $input->getOption('validate-git-archive'); 307 | $globPattern = $input->getOption('glob-pattern'); 308 | $globPatternFile = (string) $input->getOption('glob-pattern-file'); 309 | $omitHeader = (boolean) $input->getOption('omit-header'); 310 | $showDifference = $input->getOption('diff'); 311 | $reportStaleExportIgnores = $input->getOption('report-stale-export-ignores'); 312 | 313 | $enforceStrictOrderComparison = $input->getOption('enforce-strict-order'); 314 | $sortFromDirectoriesToFiles = $input->getOption('sort-from-directories-to-files'); 315 | 316 | if ($enforceStrictOrderComparison && $sortFromDirectoriesToFiles === false) { 317 | $verboseOutput = '+ Enforcing strict order comparison.'; 318 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 319 | 320 | $this->analyser->enableStrictOrderComparison(); 321 | } 322 | 323 | if ($sortFromDirectoriesToFiles) { 324 | $verboseOutput = '+ Sorting from files to directories.'; 325 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 326 | 327 | $this->analyser->sortFromDirectoriesToFiles(); 328 | } 329 | 330 | if ($reportStaleExportIgnores) { 331 | $verboseOutput = '+ Enforcing stale export ignores comparison.'; 332 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 333 | 334 | $this->analyser->enableStaleExportIgnoresComparison(); 335 | } 336 | 337 | $enforceExportIgnoresAlignment = $input->getOption('enforce-alignment'); 338 | 339 | if ($enforceExportIgnoresAlignment) { 340 | $verboseOutput = '+ Enforcing alignment comparison.'; 341 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 342 | 343 | $this->analyser->enableStrictAlignmentComparison(); 344 | } 345 | 346 | $keepLicense = (boolean) $input->getOption('keep-license'); 347 | 348 | if ($keepLicense) { 349 | $verboseOutput = '+ Keeping the license file.'; 350 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 351 | 352 | $this->analyser->keepLicense(); 353 | } 354 | 355 | $keepReadme = (boolean) $input->getOption('keep-readme'); 356 | 357 | if ($keepReadme) { 358 | $verboseOutput = '+ Keeping the README file.'; 359 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 360 | 361 | $this->analyser->keepReadme(); 362 | } 363 | 364 | $keepGlobPattern = (string) $input->getOption('keep-glob-pattern'); 365 | 366 | if ($keepGlobPattern !== '') { 367 | $verboseOutput = \sprintf('+ Keeping files matching the glob pattern %s.', $keepGlobPattern); 368 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 369 | try { 370 | $this->analyser->setKeepGlobPattern($keepGlobPattern); 371 | } catch (InvalidGlobPattern $e) { 372 | $warning = "Warning: The provided glob pattern " 373 | . "'$keepGlobPattern' is considered invalid."; 374 | $outputContent = '' . $warning . ''; 375 | $output->writeln($outputContent); 376 | 377 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 378 | 379 | return Command::FAILURE; 380 | } 381 | } 382 | 383 | $alignExportIgnores = $input->getOption('align-export-ignores'); 384 | 385 | if ($alignExportIgnores) { 386 | $verboseOutput = '+ Aligning the export-ignores.'; 387 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 388 | 389 | $this->analyser->alignExportIgnores(); 390 | } 391 | 392 | if ($globPattern || $globPattern === '') { 393 | try { 394 | $verboseOutput = "+ Using glob pattern $globPattern."; 395 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 396 | 397 | $this->analyser->setGlobPattern((string) $globPattern); 398 | } catch (InvalidGlobPattern $e) { 399 | $warning = "Warning: The provided glob pattern " 400 | . "'$globPattern' is considered invalid."; 401 | $outputContent = '' . $warning . ''; 402 | $output->writeln($outputContent); 403 | 404 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 405 | 406 | return Command::FAILURE; 407 | } 408 | } elseif ($this->isGlobPatternFileSettable($globPatternFile)) { 409 | try { 410 | if ($this->isDefaultGlobPatternFilePresent()) { 411 | $this->analyser->setGlobPatternFromFile($globPatternFile); 412 | } 413 | if ($globPatternFile) { 414 | $globPatternFileInfo = new SplFileInfo($globPatternFile); 415 | $verboseOutput = '+ Using ' . $globPatternFileInfo->getBasename() . ' file as glob pattern input.'; 416 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 417 | 418 | $this->analyser->setGlobPatternFromFile($globPatternFile); 419 | } 420 | 421 | } catch (NonExistentGlobPatternFile $e) { 422 | $warning = "Warning: The provided glob pattern file " 423 | . "'$globPatternFile' doesn't exist."; 424 | $outputContent = '' . $warning . ''; 425 | $output->writeln($outputContent); 426 | 427 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 428 | 429 | return Command::FAILURE; 430 | } catch (InvalidGlobPatternFile $e) { 431 | $warning = "Warning: The provided glob pattern file " 432 | . "'$globPatternFile' is considered invalid."; 433 | $outputContent = '' . $warning . ''; 434 | $output->writeln($outputContent); 435 | 436 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 437 | 438 | return Command::FAILURE; 439 | } 440 | } elseif ($chosenPreset) { 441 | try { 442 | 443 | $verboseOutput = '+ Using the ' . $chosenPreset . ' language preset.'; 444 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 445 | 446 | $this->analyser->setGlobPatternFromPreset($chosenPreset); 447 | } catch (PresetNotAvailable $e) { 448 | $warning = 'Warning: The chosen language preset ' . $chosenPreset . ' is not available. Maybe contribute it?.'; 449 | $outputContent = '' . $warning . ''; 450 | $output->writeln($outputContent); 451 | 452 | return Command::FAILURE; 453 | } 454 | } 455 | 456 | $verboseOutput = '+ Checking .gitattribute file existence in ' . $directory . '.'; 457 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 458 | $outputContent = ''; 459 | 460 | if (!$this->analyser->hasGitattributesFile()) { 461 | if ($createGitattributesFile === false) { 462 | $warning = 'Warning: There is no .gitattributes file present in ' 463 | . $this->analyser->getDirectory() . '.'; 464 | $outputContent.= '' . $warning . ''; 465 | } 466 | 467 | $verboseOutput = '+ Getting expected .gitattribute file content.'; 468 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 469 | 470 | $expectedGitattributesFileContent = $this->analyser 471 | ->getExpectedGitattributesContent(); 472 | 473 | if ($expectedGitattributesFileContent !== '') { 474 | 475 | if ($createGitattributesFile || $overwriteGitattributesFile) { 476 | try { 477 | $outputContent .= $this->gitattributesFileRepository->createGitattributesFile( 478 | $expectedGitattributesFileContent, 479 | $omitHeader === true ? false : true 480 | ); 481 | $output->writeln($outputContent); 482 | 483 | return Command::SUCCESS; 484 | } catch (GitattributesCreationFailed $e) { 485 | $outputContent .= PHP_EOL . PHP_EOL . $e->getMessage(); 486 | $output->writeln($outputContent); 487 | 488 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 489 | 490 | return Command::FAILURE; 491 | } 492 | } else { 493 | $verboseOutput = '+ Suggesting .gitattribute file content.'; 494 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 495 | 496 | $outputContent .= $this->getSuggestGitattributesFileCreationOptionOutput( 497 | $expectedGitattributesFileContent 498 | ); 499 | 500 | $output->writeln($outputContent); 501 | 502 | return Command::FAILURE; 503 | } 504 | } 505 | 506 | $outputContent .= PHP_EOL . PHP_EOL . 'Unable to resolve expected .gitattributes ' 507 | . 'content.'; 508 | $output->writeln($outputContent); 509 | 510 | return Command::FAILURE; 511 | } elseif ($validateArchive) { 512 | try { 513 | $verboseOutput = '+ Validating Git archive.'; 514 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 515 | 516 | if ($this->isValidArchive($keepLicense)) { 517 | $info = 'The archive file of the current HEAD is considered lean.'; 518 | $output->writeln($info); 519 | 520 | return Command::SUCCESS; 521 | } 522 | $foundUnexpectedArchiveArtifacts = $this->archiveValidator 523 | ->getFoundUnexpectedArchiveArtifacts(); 524 | 525 | $info = 'The archive file of the current HEAD is not considered lean.' 526 | . PHP_EOL . PHP_EOL . 'Seems like the following artifacts slipped in:' . PHP_EOL 527 | . \implode(PHP_EOL, $foundUnexpectedArchiveArtifacts) . '' . PHP_EOL; 528 | 529 | if (\count($this->archiveValidator->getFoundUnexpectedArchiveArtifacts()) === 1) { 530 | $info = 'The archive file of the current HEAD is not considered lean.' 531 | . PHP_EOL . PHP_EOL . 'Seems like the following artifact slipped in:' . PHP_EOL 532 | . \implode(PHP_EOL, $foundUnexpectedArchiveArtifacts) . '' . PHP_EOL; 533 | } 534 | } catch (NoLicenseFilePresent $e) { 535 | $errorMessage = 'The archive file of the current HEAD ' 536 | . 'is considered invalid due to a missing license file.'; 537 | $info = '' . $errorMessage . '' . PHP_EOL; 538 | 539 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 540 | 541 | $this->archiveValidator->getArchive()->removeArchive(); 542 | } 543 | 544 | $output->writeln($info); 545 | 546 | return Command::FAILURE; 547 | } else { 548 | $verboseOutput = '+ Analysing the .gitattribute content.'; 549 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 550 | 551 | if ($this->analyser->hasCompleteExportIgnores() === false) { 552 | $outputContent = 'The present .gitattributes file is considered invalid.'; 553 | 554 | $verboseOutput = "+ Gathering expected .gitattribute content."; 555 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 556 | 557 | $expectedGitattributesFileContent = $this->analyser 558 | ->getExpectedGitattributesContent(); 559 | 560 | if ($createGitattributesFile || $overwriteGitattributesFile) { 561 | try { 562 | $verboseOutput = "+ Trying to create expected .gitattribute file."; 563 | if ($overwriteGitattributesFile) { 564 | $verboseOutput = "+ Trying to overwrite existing .gitattribute file with expected content."; 565 | } 566 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 567 | 568 | $outputContent .= $this->gitattributesFileRepository->overwriteGitattributesFile( 569 | $expectedGitattributesFileContent 570 | ); 571 | 572 | $output->writeln($outputContent); 573 | 574 | return Command::SUCCESS; 575 | } catch (GitattributesCreationFailed $e) { 576 | $outputContent .= PHP_EOL . PHP_EOL . $e->getMessage(); 577 | $output->writeln($outputContent); 578 | 579 | $output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG); 580 | 581 | return Command::FAILURE; 582 | } 583 | } 584 | 585 | if ($showDifference) { 586 | $actual = $this->analyser->getPresentGitAttributesContent(); 587 | $builder = new UnifiedDiffOutputBuilder( 588 | "--- Original" . PHP_EOL . "+++ Expected" . PHP_EOL, 589 | true 590 | ); 591 | $differ = new Differ($builder); 592 | $expectedGitattributesFileContent = $differ->diff($actual, $expectedGitattributesFileContent); 593 | } 594 | 595 | $outputContent .= $this->getExpectedGitattributesFileContentOutput( 596 | $expectedGitattributesFileContent 597 | ); 598 | 599 | $output->writeln($outputContent); 600 | 601 | return Command::FAILURE; 602 | } 603 | 604 | $info = 'The present .gitattributes file is considered valid.'; 605 | $output->writeln($info); 606 | 607 | if ($this->analyser->hasPrecedingSlashesInExportIgnorePattern()) { 608 | $verboseOutput = '+ Checking for preceding slashes in export-ignore statements.'; 609 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 610 | 611 | $warning = "Warning: At least one export-ignore pattern has a leading '/', " 612 | . 'which is considered as a smell.'; 613 | $outputContent = '' . $warning . ''; 614 | $output->writeln($outputContent); 615 | } 616 | 617 | if ($this->analyser->hasTextAutoConfiguration() === false) { 618 | $verboseOutput = '+ Checking for text auto configuration.'; 619 | $output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE); 620 | 621 | $warning = 'Warning: Missing a text auto configuration. ' 622 | . 'Consider adding one.'; 623 | $outputContent = '' . $warning . ''; 624 | $output->writeln($outputContent); 625 | } 626 | 627 | return Command::SUCCESS; 628 | } 629 | } 630 | 631 | /** 632 | * Check if a glob pattern file is settable. 633 | * 634 | * @param string $file The glob pattern file to check. 635 | * @return boolean 636 | */ 637 | protected function isGlobPatternFileSettable(string $file): bool 638 | { 639 | if ($this->isGlobPatternFileProvided($file)) { 640 | return true; 641 | } 642 | 643 | return $this->isDefaultGlobPatternFilePresent(); 644 | } 645 | 646 | /** 647 | * Check if a glob pattern file was provided. 648 | * 649 | * @param string $file The glob pattern file provided. 650 | * @return boolean 651 | */ 652 | protected function isGlobPatternFileProvided(string $file): bool 653 | { 654 | return $file !== $this->defaultLpvFile; 655 | } 656 | 657 | /** 658 | * Check if a default glob pattern file (.lpv) is present. 659 | * 660 | * @return boolean 661 | */ 662 | protected function isDefaultGlobPatternFilePresent(): bool 663 | { 664 | return \file_exists($this->defaultLpvFile); 665 | } 666 | 667 | /** 668 | * Validate archive of current Git HEAD. 669 | * 670 | * @param boolean $validateLicenseFilePresence Whether the archive should have a license file or not. 671 | * @throws GitNotAvailable|NoLicenseFilePresent 672 | * @throws GitHeadNotAvailable 673 | * @return boolean 674 | */ 675 | protected function isValidArchive(bool $validateLicenseFilePresence = false): bool 676 | { 677 | if ($validateLicenseFilePresence) { 678 | return $this->archiveValidator->shouldHaveLicenseFile()->validate( 679 | $this->analyser->collectExpectedExportIgnores() 680 | ); 681 | } 682 | 683 | return $this->archiveValidator->validate( 684 | $this->analyser->collectExpectedExportIgnores() 685 | ); 686 | } 687 | 688 | /** 689 | * Get expected gitattributes file content output content. 690 | * 691 | * @param string $expectedGitattributesFileContent 692 | * 693 | * @return string 694 | */ 695 | protected function getExpectedGitattributesFileContentOutput( 696 | string $expectedGitattributesFileContent 697 | ): string { 698 | $content = 'Would expect the following .gitattributes file content:' . PHP_EOL 699 | . '' . $expectedGitattributesFileContent . ''; 700 | 701 | return \str_repeat(PHP_EOL, 2) . $content; 702 | } 703 | 704 | /** 705 | * Get suggest gitattributes file creation output content. 706 | * 707 | * @param string $expectedGitattributesFileContent 708 | * 709 | * @return string 710 | */ 711 | protected function getSuggestGitattributesFileCreationOptionOutput( 712 | string $expectedGitattributesFileContent 713 | ): string { 714 | $content = 'Would expect the following .gitattributes file content:' . PHP_EOL 715 | . '' . $expectedGitattributesFileContent . '' . PHP_EOL 716 | . 'Use the --create|-c option to create a ' 717 | . '.gitattributes file with the shown content.'; 718 | 719 | return PHP_EOL . PHP_EOL . $content; 720 | } 721 | } 722 | -------------------------------------------------------------------------------- /src/Analyser.php: -------------------------------------------------------------------------------- 1 | finder = $finder; 146 | $this->defaultGlobPattern = $finder->getDefaultPreset(); 147 | 148 | $this->globPattern = '{' . \implode(',', $this->defaultGlobPattern) . '}*'; 149 | } 150 | 151 | /** 152 | * Accessor for the injected finder. 153 | * 154 | * @return Finder 155 | */ 156 | public function getFinder(): Finder 157 | { 158 | return $this->finder; 159 | } 160 | 161 | /** 162 | * Accessor for the default glob patterns. 163 | * 164 | * @return array 165 | */ 166 | public function getDefaultGlobPattern(): array 167 | { 168 | return $this->defaultGlobPattern; 169 | } 170 | 171 | /** 172 | * Accessor for preceding slashes in export-ignore pattern. 173 | * 174 | * @return boolean 175 | */ 176 | public function hasPrecedingSlashesInExportIgnorePattern(): bool 177 | { 178 | return $this->hasPrecedingSlashesInExportIgnorePattern; 179 | } 180 | 181 | /** 182 | * Accessor for text auto configuration. 183 | * 184 | * @return boolean 185 | */ 186 | public function hasTextAutoConfiguration(): bool 187 | { 188 | return $this->hasTextAutoConfiguration; 189 | } 190 | 191 | /** 192 | * Set the glob pattern file. 193 | * 194 | * @param string $file 195 | * @throws \Stolt\LeanPackage\Exceptions\NonExistentGlobPatternFile 196 | * @throws \Stolt\LeanPackage\Exceptions\InvalidGlobPatternFile 197 | * @return Analyser 198 | */ 199 | public function setGlobPatternFromFile(string $file): Analyser 200 | { 201 | if (!\is_file($file)) { 202 | $message = "Glob pattern file {$file} doesn't exist."; 203 | throw new NonExistentGlobPatternFile($message); 204 | } 205 | 206 | $globPatternContent = (string) \file_get_contents($file); 207 | 208 | $globPatternLines = \preg_split( 209 | '/\\r\\n|\\r|\\n/', 210 | $globPatternContent 211 | ); 212 | 213 | $globPatterns = []; 214 | \array_filter($globPatternLines, function ($line) use (&$globPatterns) { 215 | if (\trim($line) !== '') { 216 | $globPatterns[] = \trim($line); 217 | } 218 | }); 219 | 220 | $globPattern = '{' . \implode(',', $globPatterns) . '}*'; 221 | 222 | try { 223 | $this->setGlobPattern($globPattern); 224 | 225 | return $this; 226 | } catch (InvalidGlobPattern $e) { 227 | $message = "Glob pattern file '{$file}' is invalid."; 228 | throw new InvalidGlobPatternFile($message); 229 | } 230 | } 231 | 232 | /** 233 | * Guard the given glob pattern. 234 | * 235 | * @param string $pattern 236 | * @throws InvalidGlobPattern 237 | * @return void 238 | */ 239 | private function guardGlobPattern(string $pattern): void 240 | { 241 | $invalidGlobPattern = false; 242 | 243 | if (\substr($pattern, 0) !== '{' 244 | && (\substr($pattern, -1) !== '}' && \substr($pattern, -2) !== '}*')) { 245 | $invalidGlobPattern = true; 246 | } 247 | 248 | $bracesContent = \trim(\substr($pattern, 1, -1)); 249 | 250 | if (empty($bracesContent)) { 251 | $invalidGlobPattern = true; 252 | } 253 | 254 | $globPatterns = \explode(',', $bracesContent); 255 | 256 | if (\count($globPatterns) == 1) { 257 | $invalidGlobPattern = true; 258 | } 259 | 260 | if ($invalidGlobPattern === true) { 261 | throw new InvalidGlobPattern; 262 | } 263 | } 264 | 265 | /** 266 | * Overwrite the default glob pattern. 267 | * 268 | * @param string $pattern The glob pattern to use to detect expected 269 | * export-ignores files. 270 | * 271 | * @throws \Stolt\LeanPackage\Exceptions\InvalidGlobPattern 272 | * @return Analyser 273 | * @return Analyser 274 | * 275 | */ 276 | public function setGlobPattern($pattern): Analyser 277 | { 278 | $this->globPattern = \trim($pattern); 279 | $this->guardGlobPattern($this->globPattern); 280 | 281 | return $this; 282 | } 283 | 284 | /** 285 | * Set the directory to analyse. 286 | * 287 | * @param string $directory The directory to analyse. 288 | * @throws \RuntimeException 289 | * @return Analyser 290 | * @return Analyser 291 | * 292 | */ 293 | public function setDirectory($directory = __DIR__): Analyser 294 | { 295 | if (!\is_dir($directory)) { 296 | $message = "Directory {$directory} doesn't exist."; 297 | throw new \RuntimeException($message); 298 | } 299 | $this->directory = $directory; 300 | $this->gitattributesFile = $directory 301 | . DIRECTORY_SEPARATOR 302 | . '.gitattributes'; 303 | 304 | return $this; 305 | } 306 | 307 | /** 308 | * Accessor for the set directory. 309 | * 310 | * @return string 311 | */ 312 | public function getDirectory(): string 313 | { 314 | return $this->directory; 315 | } 316 | 317 | /** 318 | * Enable strict order comparison. 319 | * 320 | * @return Analyser 321 | */ 322 | public function enableStrictOrderComparison(): Analyser 323 | { 324 | $this->strictOrderComparison = true; 325 | 326 | return $this; 327 | } 328 | 329 | public function sortFromDirectoriesToFiles(): Analyser 330 | { 331 | $this->sortFromDirectoriesToFiles = true; 332 | 333 | return $this; 334 | } 335 | 336 | /** 337 | * Guard for strict order comparison. 338 | * 339 | * @return boolean 340 | */ 341 | public function isStrictOrderComparisonEnabled(): bool 342 | { 343 | return $this->strictOrderComparison === true; 344 | } 345 | 346 | /** 347 | * Enable stale export ignores comparison. 348 | * 349 | * @return Analyser 350 | */ 351 | public function enableStaleExportIgnoresComparison(): Analyser 352 | { 353 | $this->staleExportIgnoresComparison = true; 354 | 355 | return $this; 356 | } 357 | 358 | /** 359 | * Guard for stale export ignores comparison. 360 | * 361 | * @return boolean 362 | */ 363 | public function isStaleExportIgnoresComparisonEnabled(): bool 364 | { 365 | return $this->staleExportIgnoresComparison === true; 366 | } 367 | 368 | /** 369 | * Enable strict alignment comparison. 370 | * 371 | * @return Analyser 372 | */ 373 | public function enableStrictAlignmentComparison(): Analyser 374 | { 375 | $this->strictAlignmentComparison = true; 376 | 377 | return $this; 378 | } 379 | 380 | /** 381 | * Guard for strict alignment comparison. 382 | * 383 | * @return boolean 384 | */ 385 | public function isStrictAlignmentComparisonEnabled(): bool 386 | { 387 | return $this->strictAlignmentComparison === true; 388 | } 389 | 390 | /** 391 | * Keep license file in releases. 392 | * 393 | * @return Analyser 394 | */ 395 | public function keepLicense(): Analyser 396 | { 397 | $this->keepLicense = true; 398 | 399 | return $this; 400 | } 401 | 402 | /** 403 | * Guard for not export-ignoring license file. 404 | * 405 | * @return boolean 406 | */ 407 | public function isKeepLicenseEnabled(): bool 408 | { 409 | return $this->keepLicense === true; 410 | } 411 | 412 | /** 413 | * Keep README file in releases. 414 | * 415 | * @return Analyser 416 | */ 417 | public function keepReadme(): Analyser 418 | { 419 | $this->keepReadme = true; 420 | 421 | return $this; 422 | } 423 | 424 | /** 425 | * Guard for not export-ignoring README file. 426 | * 427 | * @return boolean 428 | */ 429 | public function isKeepReadmeEnabled(): bool 430 | { 431 | return $this->keepReadme === true; 432 | } 433 | 434 | /** 435 | * Sets the glob pattern for not export-ignoring license files. 436 | * 437 | * @param string $globPattern 438 | * @throws InvalidGlobPattern 439 | * @return Analyser 440 | */ 441 | public function setKeepGlobPattern(string $globPattern): Analyser 442 | { 443 | $this->guardGlobPattern($globPattern); 444 | $this->keepGlobPattern = $globPattern; 445 | 446 | return $this; 447 | } 448 | 449 | /** 450 | * Guard for not export-ignoring glob pattern. 451 | * 452 | * @return boolean 453 | */ 454 | public function isKeepGlobPatternSet(): bool 455 | { 456 | return $this->keepGlobPattern !== ''; 457 | } 458 | 459 | /** 460 | * Align export-ignores. 461 | * 462 | * @return Analyser 463 | */ 464 | public function alignExportIgnores(): Analyser 465 | { 466 | $this->alignExportIgnores = true; 467 | 468 | return $this; 469 | } 470 | 471 | /** 472 | * Guard for aligning export-ignores. 473 | * 474 | * @return boolean 475 | */ 476 | public function isAlignExportIgnoresEnabled(): bool 477 | { 478 | return $this->alignExportIgnores === true; 479 | } 480 | 481 | /** 482 | * Accessor for the set .gitattributes file path. 483 | * 484 | * @return string 485 | */ 486 | public function getGitattributesFilePath(): string 487 | { 488 | return $this->gitattributesFile; 489 | } 490 | 491 | /** 492 | * Is a .gitattributes file present? 493 | * 494 | * @return boolean 495 | */ 496 | public function hasGitattributesFile(): bool 497 | { 498 | return \file_exists($this->gitattributesFile) && 499 | \is_readable($this->gitattributesFile); 500 | } 501 | 502 | /** 503 | * @param string $gitignoreFile 504 | * @return array 505 | */ 506 | private function getGitignorePatterns(string $gitignoreFile): array 507 | { 508 | if (!\file_exists($gitignoreFile)) { 509 | return []; 510 | } 511 | 512 | $gitignoreContent = (string) \file_get_contents($gitignoreFile); 513 | $eol = $this->detectEol($gitignoreContent); 514 | 515 | $gitignoreLines = \preg_split( 516 | '/\\r\\n|\\r|\\n/', 517 | $gitignoreContent 518 | ); 519 | 520 | $gitignoredPatterns = []; 521 | 522 | \array_filter($gitignoreLines, function ($line) use (&$gitignoredPatterns) { 523 | $line = \trim($line); 524 | if ($line !== '' && \strpos($line, '#') === false) { 525 | if (\substr($line, 0, 1) === "/") { 526 | $gitignoredPatterns[] = \substr($line, 1); 527 | } 528 | if (\substr($line, -1, 1) === "/") { 529 | $gitignoredPatterns[] = \substr($line, 0, -1); 530 | } 531 | $gitignoredPatterns[] = $line; 532 | } 533 | }); 534 | 535 | return $gitignoredPatterns; 536 | } 537 | 538 | /** 539 | * Return patterns in .gitignore file. 540 | * 541 | * @return array 542 | */ 543 | public function getGitignoredPatterns(): array 544 | { 545 | $gitignoreFile = $this->getDirectory() . DIRECTORY_SEPARATOR . '.gitignore'; 546 | 547 | return $this->getGitignorePatterns($gitignoreFile); 548 | } 549 | 550 | /** 551 | * Return the expected .gitattributes content. 552 | * 553 | * @param array $postfixLessExportIgnores Expected patterns without an export-ignore postfix. 554 | * @return string 555 | */ 556 | public function getExpectedGitattributesContent(array $postfixLessExportIgnores = []): string 557 | { 558 | if ($postfixLessExportIgnores === []) { 559 | $postfixLessExportIgnores = $this->collectExpectedExportIgnores(); 560 | } 561 | 562 | if (!$this->hasGitattributesFile() && \count($postfixLessExportIgnores) > 0) { 563 | $postfixLessExportIgnores[] = '.gitattributes'; 564 | } 565 | 566 | \sort($postfixLessExportIgnores, SORT_STRING | SORT_FLAG_CASE); 567 | 568 | if (\count($postfixLessExportIgnores) > 0) { 569 | if ($this->sortFromDirectoriesToFiles === false && ($this->isAlignExportIgnoresEnabled() || $this->isStrictAlignmentComparisonEnabled())) { 570 | $postfixLessExportIgnores = $this->getAlignedExportIgnoreArtifacts( 571 | $postfixLessExportIgnores 572 | ); 573 | } 574 | 575 | if ($this->sortFromDirectoriesToFiles) { 576 | $postfixLessExportIgnores = $this->getByDirectoriesToFilesExportIgnoreArtifacts( 577 | $postfixLessExportIgnores 578 | ); 579 | } 580 | 581 | $content = \implode(" export-ignore" . $this->preferredEol, $postfixLessExportIgnores) 582 | . " export-ignore" . $this->preferredEol; 583 | 584 | if ($this->hasGitattributesFile()) { 585 | $exportIgnoreContent = \rtrim($content); 586 | $content = $this->getPresentNonExportIgnoresContent(); 587 | 588 | if (\strstr($content, self::EXPORT_IGNORES_PLACEMENT_PLACEHOLDER)) { 589 | $content = \str_replace( 590 | self::EXPORT_IGNORES_PLACEMENT_PLACEHOLDER, 591 | $exportIgnoreContent, 592 | $content 593 | ); 594 | } else { 595 | $content = $content 596 | . \str_repeat($this->preferredEol, 2) 597 | . $exportIgnoreContent; 598 | } 599 | } else { 600 | $content = "* text=auto eol=lf" . \str_repeat($this->preferredEol, 2) . $content; 601 | } 602 | 603 | return $content; 604 | } 605 | 606 | return ''; 607 | } 608 | 609 | /** 610 | * Return export ignores in .gitattributes file to preserve. 611 | * 612 | * @param array $globPatternMatchingExportIgnores Export ignores matching glob pattern. 613 | * 614 | * @return array 615 | */ 616 | public function getPresentExportIgnoresToPreserve(array $globPatternMatchingExportIgnores): array 617 | { 618 | $gitattributesContent = (string) \file_get_contents($this->gitattributesFile); 619 | 620 | if (\preg_match("/(\*\h*)(text\h*)(=\h*auto)/", $gitattributesContent)) { 621 | $this->hasTextAutoConfiguration = true; 622 | } 623 | 624 | $eol = $this->detectEol($gitattributesContent); 625 | 626 | $gitattributesLines = \preg_split( 627 | '/\\r\\n|\\r|\\n/', 628 | $gitattributesContent 629 | ); 630 | 631 | $basenamedGlobPatternMatchingExportIgnores = \array_map( 632 | 'basename', 633 | $globPatternMatchingExportIgnores 634 | ); 635 | 636 | $exportIgnoresToPreserve = []; 637 | 638 | \array_filter($gitattributesLines, function ($line) use ( 639 | &$exportIgnoresToPreserve, 640 | &$globPatternMatchingExportIgnores, 641 | &$basenamedGlobPatternMatchingExportIgnores 642 | ) { 643 | if (\strstr($line, 'export-ignore') && \strpos($line, '#') === false) { 644 | list($pattern, $void) = \explode('export-ignore', $line); 645 | if (\substr($pattern, 0, 1) === '/') { 646 | $pattern = \substr($pattern, 1); 647 | $this->hasPrecedingSlashesInExportIgnorePattern = true; 648 | } 649 | $patternMatches = $this->patternHasMatch($pattern); 650 | $pattern = \trim($pattern); 651 | 652 | if ($patternMatches 653 | && !\in_array($pattern, $globPatternMatchingExportIgnores) 654 | && !\in_array($pattern, $basenamedGlobPatternMatchingExportIgnores) 655 | ) { 656 | return $exportIgnoresToPreserve[] = \trim($pattern); 657 | } 658 | } 659 | }); 660 | 661 | return $exportIgnoresToPreserve; 662 | } 663 | 664 | /** 665 | * Collect the expected export-ignored files. 666 | * 667 | * @return array 668 | */ 669 | public function collectExpectedExportIgnores(): array 670 | { 671 | $expectedExportIgnores = []; 672 | 673 | $initialWorkingDirectory = (string) \getcwd(); 674 | 675 | \chdir($this->directory); 676 | 677 | $ignoredGlobMatches = \array_merge( 678 | $this->ignoredGlobMatches, 679 | $this->getGitignoredPatterns() 680 | ); 681 | 682 | $globMatches = Glob::glob($this->globPattern, Glob::GLOB_BRACE); 683 | 684 | if (!\is_array($globMatches)) { 685 | return $expectedExportIgnores; 686 | } 687 | 688 | foreach ($globMatches as $filename) { 689 | if (!\in_array($filename, $ignoredGlobMatches)) { 690 | if (\is_dir($filename)) { 691 | $expectedExportIgnores[] = $filename . '/'; 692 | continue; 693 | } 694 | $expectedExportIgnores[] = $filename; 695 | } 696 | } 697 | 698 | \chdir($initialWorkingDirectory); 699 | 700 | if ($this->hasGitattributesFile()) { 701 | $expectedExportIgnores = \array_merge( 702 | $expectedExportIgnores, 703 | $this->getPresentExportIgnoresToPreserve($expectedExportIgnores) 704 | ); 705 | } 706 | 707 | \sort($expectedExportIgnores, SORT_STRING | SORT_FLAG_CASE); 708 | 709 | if ($this->isKeepLicenseEnabled()) { 710 | $licenseLessExpectedExportIgnores = []; 711 | \array_filter($expectedExportIgnores, function ($exportIgnore) use ( 712 | &$licenseLessExpectedExportIgnores 713 | ) { 714 | if (!\preg_match('/(License.*)/i', $exportIgnore)) { 715 | $licenseLessExpectedExportIgnores[] = $exportIgnore; 716 | } 717 | }); 718 | 719 | $expectedExportIgnores = $licenseLessExpectedExportIgnores; 720 | } 721 | 722 | if ($this->isKeepReadmeEnabled()) { 723 | $readmeLessExpectedExportIgnores = []; 724 | \array_filter($expectedExportIgnores, function ($exportIgnore) use ( 725 | &$readmeLessExpectedExportIgnores 726 | ) { 727 | if (!\preg_match('/(Readme.*)/i', $exportIgnore)) { 728 | $readmeLessExpectedExportIgnores[] = $exportIgnore; 729 | } 730 | }); 731 | 732 | $expectedExportIgnores = $readmeLessExpectedExportIgnores; 733 | } 734 | 735 | if ($this->isKeepGlobPatternSet()) { 736 | $excludes = Glob::globArray($this->keepGlobPattern, $expectedExportIgnores); 737 | $expectedExportIgnores = \array_diff($expectedExportIgnores, $excludes); 738 | } 739 | 740 | return \array_unique($expectedExportIgnores); 741 | } 742 | 743 | /** 744 | * Detect most frequently used end of line sequence. 745 | * 746 | * @param string $content The content to detect the eol in. 747 | * 748 | * @return string 749 | */ 750 | private function detectEol($content): string 751 | { 752 | $maxCount = 0; 753 | $preferredEol = $this->preferredEol; 754 | $eols = ["\n", "\r", "\n\r", "\r\n"]; 755 | 756 | foreach ($eols as $eol) { 757 | if (($count = \substr_count($content, $eol)) >= $maxCount) { 758 | $maxCount = $count; 759 | $preferredEol = $eol; 760 | } 761 | } 762 | 763 | $this->preferredEol = $preferredEol; 764 | 765 | return $preferredEol; 766 | } 767 | 768 | /** 769 | * Check if a given pattern produces a match 770 | * against the repository directory. 771 | * 772 | * @param string $globPattern 773 | * @return boolean 774 | */ 775 | private function patternHasMatch($globPattern): bool 776 | { 777 | if (\substr(\trim($globPattern), 0, 1) === '/') { 778 | $globPattern = \trim(\substr($globPattern, 1)); 779 | } elseif (\substr(\trim($globPattern), -1) === '/') { 780 | $globPattern = \trim(\substr($globPattern, 0, -1)); 781 | } else { 782 | $globPattern = '{' . \trim($globPattern) . '}*'; 783 | } 784 | 785 | $initialWorkingDirectory = (string) \getcwd(); 786 | \chdir($this->directory); 787 | 788 | $matches = Glob::glob($globPattern, Glob::GLOB_BRACE); 789 | 790 | \chdir($initialWorkingDirectory); 791 | 792 | return \is_array($matches) && \count($matches) > 0; 793 | } 794 | 795 | /** 796 | * @return string 797 | */ 798 | public function getPresentGitAttributesContent(): string 799 | { 800 | if ($this->hasGitattributesFile() === false) { 801 | return ''; 802 | } 803 | 804 | return (string) \file_get_contents($this->gitattributesFile); 805 | } 806 | 807 | /** 808 | * Get the present non export-ignore entries of 809 | * the .gitattributes file. 810 | * 811 | * @return string 812 | */ 813 | public function getPresentNonExportIgnoresContent(): string 814 | { 815 | if ($this->hasGitattributesFile() === false) { 816 | return ''; 817 | } 818 | 819 | $gitattributesContent = (string) \file_get_contents($this->gitattributesFile); 820 | $eol = $this->detectEol($gitattributesContent); 821 | 822 | $gitattributesLines = \preg_split( 823 | '/\\r\\n|\\r|\\n/', 824 | $gitattributesContent 825 | ); 826 | 827 | $nonExportIgnoreLines = []; 828 | $exportIgnoresPlacementPlaceholderSet = false; 829 | $exportIgnoresPlacementPlaceholder = self::EXPORT_IGNORES_PLACEMENT_PLACEHOLDER; 830 | 831 | \array_filter($gitattributesLines, function ($line) use ( 832 | &$nonExportIgnoreLines, 833 | &$exportIgnoresPlacementPlaceholderSet, 834 | &$exportIgnoresPlacementPlaceholder 835 | ) { 836 | if (\strstr($line, 'export-ignore') === false || \strstr($line, '#')) { 837 | return $nonExportIgnoreLines[] = \trim($line); 838 | } else { 839 | if ($exportIgnoresPlacementPlaceholderSet === false) { 840 | $exportIgnoresPlacementPlaceholderSet = true; 841 | return $nonExportIgnoreLines[] = $exportIgnoresPlacementPlaceholder; 842 | } 843 | } 844 | }); 845 | 846 | return \implode($eol, $nonExportIgnoreLines); 847 | } 848 | 849 | /** 850 | * Get the present export-ignore entries of 851 | * the .gitattributes file. 852 | * 853 | * @param bool $applyGlob 854 | * @param string $gitattributesContent 855 | * @return array 856 | */ 857 | public function getPresentExportIgnores(bool $applyGlob = true, string $gitattributesContent = ''): array 858 | { 859 | if ($this->hasGitattributesFile() === false && $gitattributesContent === '') { 860 | return []; 861 | } 862 | 863 | if ($gitattributesContent === '') { 864 | $gitattributesContent = (string) \file_get_contents($this->gitattributesFile); 865 | } 866 | 867 | $gitattributesLines = \preg_split( 868 | '/\\r\\n|\\r|\\n/', 869 | $gitattributesContent 870 | ); 871 | 872 | $exportIgnores = []; 873 | \array_filter($gitattributesLines, function ($line) use (&$exportIgnores, &$applyGlob) { 874 | if (\strstr($line, 'export-ignore', true)) { 875 | list($line, $void) = \explode('export-ignore', $line); 876 | if ($applyGlob) { 877 | if ($this->patternHasMatch(\trim($line))) { 878 | if (\substr($line, 0, 1) === '/') { 879 | $line = \substr($line, 1); 880 | } 881 | 882 | return $exportIgnores[] = \trim($line); 883 | } 884 | } else { 885 | if ($this->patternHasMatch(\trim($line))) { 886 | if (\substr($line, 0, 1) === '/') { 887 | $line = \substr($line, 1); 888 | } 889 | 890 | return $exportIgnores[] = \trim($line); 891 | } else { 892 | return $exportIgnores[] = \trim($line); 893 | } 894 | } 895 | } 896 | }); 897 | 898 | if ($this->isStrictOrderComparisonEnabled() === false) { 899 | \sort($exportIgnores, SORT_STRING | SORT_FLAG_CASE); 900 | } 901 | 902 | return \array_unique($exportIgnores); 903 | } 904 | 905 | /** 906 | * @param array $artifacts The export-ignore artifacts to align. 907 | * @return array 908 | */ 909 | private function getAlignedExportIgnoreArtifacts(array $artifacts): array 910 | { 911 | $longestArtifact = \max(\array_map('strlen', $artifacts)); 912 | 913 | return \array_map(function ($artifact) use (&$longestArtifact) { 914 | if (\strlen($artifact) < $longestArtifact) { 915 | return $artifact . \str_repeat( 916 | ' ', 917 | $longestArtifact - \strlen($artifact) 918 | ); 919 | } 920 | return $artifact; 921 | }, $artifacts); 922 | } 923 | 924 | private function getByDirectoriesToFilesExportIgnoreArtifacts(array $artifacts): array 925 | { 926 | $directories = \array_filter($artifacts, function ($artifact) { 927 | if (\strpos($artifact, '/')) { 928 | return $artifact; 929 | } 930 | }); 931 | $files = \array_filter($artifacts, function ($artifact) { 932 | if (\strpos($artifact, '/') === false) { 933 | return $artifact; 934 | } 935 | }); 936 | 937 | return \array_merge($directories, $files); 938 | } 939 | 940 | public function hasCompleteExportIgnoresFromString(string $gitattributesContent): bool 941 | { 942 | $expectedExportIgnores = $this->collectExpectedExportIgnores(); 943 | $presentExportIgnores = $this->getPresentExportIgnores(true, $gitattributesContent); 944 | 945 | return \array_values($expectedExportIgnores) === \array_values($presentExportIgnores); 946 | } 947 | 948 | /** 949 | * Is existing .gitattributes file having all export-ignore(s). 950 | * 951 | */ 952 | public function hasCompleteExportIgnores(): bool 953 | { 954 | $expectedExportIgnores = $this->collectExpectedExportIgnores(); 955 | 956 | if ($expectedExportIgnores === [] || $this->hasGitattributesFile() === false) { 957 | return false; 958 | } 959 | 960 | $actualExportIgnores = $this->getPresentExportIgnores(); 961 | 962 | $staleExportIgnores = []; 963 | 964 | if ($this->isStaleExportIgnoresComparisonEnabled()) { 965 | $unfilteredExportIgnores = $this->getPresentExportIgnores(false); 966 | foreach ($unfilteredExportIgnores as $unfilteredExportIgnore) { 967 | if (false === \file_exists($unfilteredExportIgnore)) { 968 | $staleExportIgnores[] = $unfilteredExportIgnore; 969 | } 970 | } 971 | } 972 | 973 | if ($this->isStrictAlignmentComparisonEnabled()) { 974 | $expectedExportIgnores = $this->getAlignedExportIgnoreArtifacts( 975 | $expectedExportIgnores 976 | ); 977 | } 978 | 979 | if ($this->isStaleExportIgnoresComparisonEnabled()) { 980 | $actualExportIgnores = \array_merge($actualExportIgnores, $staleExportIgnores); 981 | } 982 | 983 | return \array_values($expectedExportIgnores) === \array_values($actualExportIgnores); 984 | } 985 | 986 | /** 987 | * @throws PresetNotAvailable 988 | */ 989 | public function setGlobPatternFromPreset(string $preset): void 990 | { 991 | $this->globPattern = '{' . \implode(',', $this->finder->getPresetGlobByLanguageName($preset)) . '}*'; 992 | } 993 | } 994 | --------------------------------------------------------------------------------