├── 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 |
--------------------------------------------------------------------------------