diff --git a/README.md b/README.md index 9522e21..85da66d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,29 @@ vendor/bin/monitor matrix ``` +--- + +## Merge many projects to one Monorepo + +Automate micro-services merge to one macro project, to make coding saint again. + + +
+ +### Use + +See how the monorepo project and the merge one are different. Use this knowledge to fill gaps in monorepo project. Only then create a final merge pull-request. + +```bash +vendor/bin/monitor diff-projects ../monorepo-project ../project-to-be-merged +``` + +That's it! This command will check: + +* differences in dependencies +* differences in autoload +* +
Happy coding! diff --git a/composer.json b/composer.json index 058f460..6aa992c 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "rector/monitor", - "description": "Monitor code quality for all your projects and packages in one place, with same PHP version and eliminating package conflicts.", + "description": "Monitor code quality for all your projects/packages in one place. Keep same PHP version, packages and even PHPStan extensions. Helps with merging multiple repositories to one.", "license": "proprietary", "bin": [ "bin/monitor" @@ -8,26 +8,28 @@ "require": { "php": ">=8.2", "composer/semver": "^3.4", - "illuminate/container": "^12.12", + "illuminate/container": "12.40.*", + "nette/neon": "^3.4", "nette/utils": "^4.1", "symfony/console": "^6.4", "symfony/filesystem": "^7.4", "symfony/finder": "^7.4", "symfony/process": "^7.4", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.12" }, "require-dev": { - "phpecs/phpecs": "^2.1", + "phpecs/phpecs": "^2.2", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpstan/phpstan-deprecation-rules": "^2.0", - "phpunit/phpunit": "^11.0", - "rector/rector": "^2.0.14", + "phpunit/phpunit": "^11.5", + "rector/jack": "^0.4.0", + "rector/rector": "^2.2", "shipmonk/composer-dependency-analyser": "^1.8", "symplify/phpstan-extensions": "^12.0", - "symplify/phpstan-rules": "^14.6", - "tomasvotruba/class-leak": "^2.0", - "tracy/tracy": "^2.10" + "symplify/phpstan-rules": "^14.9", + "tomasvotruba/class-leak": "^2.1", + "tracy/tracy": "^2.11" }, "autoload": { "psr-4": { diff --git a/src/Macro/Command/CompareProjectsCommand.php b/src/Macro/Command/CompareProjectsCommand.php new file mode 100644 index 0000000..fd70861 --- /dev/null +++ b/src/Macro/Command/CompareProjectsCommand.php @@ -0,0 +1,76 @@ +comparators = [ + $composerComparator, + $mutuallyMissingPackagesComparator, + $configFilesComparator, + $phpStanExtensionsComparator, + $phpStanPathsComparator, + ]; + } + + protected function configure(): void + { + $this->setName('compare-projects'); + + $this->setDescription( + 'Compare two projects and show the differences. First is the macro, monorepo project we want to merge into. Other is the project to merge merge and dismantle later.' + ); + + $this->addArgument('monorepo-directory', InputArgument::REQUIRED, 'Path to the monorepo directory'); + $this->addOption('merge-project', null, InputOption::VALUE_REQUIRED); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $monorepoDirectory = $input->getArgument('monorepo-directory'); + $monorepoProjectMetadata = new ProjectMetadata($monorepoDirectory); + + $mergeDirectory = $input->getOption('merge-project'); + $mergeProjectMetadata = new ProjectMetadata($mergeDirectory); + + $this->symfonyStyle->writeln('Both directories found. Comparing 2 projects'); + + foreach ($this->comparators as $key => $comparator) { + $comparator->compare($key + 1, $monorepoProjectMetadata, $mergeProjectMetadata); + $this->symfonyStyle->newLine(); + } + + return self::SUCCESS; + } +} diff --git a/src/Macro/Comparator/ComposerAutoloadComparator.php b/src/Macro/Comparator/ComposerAutoloadComparator.php new file mode 100644 index 0000000..5ac1b6a --- /dev/null +++ b/src/Macro/Comparator/ComposerAutoloadComparator.php @@ -0,0 +1,75 @@ +symfonyStyle->title(sprintf('%d) PSR-4 autoload differences', $step)); + + $hasDifference = false; + + foreach (self::AUTOLOAD_KEYS as $autoloadKey) { + $monorepoComposerJson = $monorepoProjectMetadata->getComposerJson(); + $mergeComposerJson = $mergeProjectMetadata->getComposerJson(); + + $autoloadDiff = ArrayUtils::diff( + $mergeComposerJson[$autoloadKey] ?? [], + $monorepoComposerJson[$autoloadKey] ?? [] + ); + + if ($autoloadDiff !== []) { + $hasDifference = true; + $this->symfonyStyle->writeln(sprintf( + 'Monorepo project and project have different "%s":', + $autoloadKey + )); + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->writeln( + sprintf('Monorepo project ("%s")', $monorepoProjectMetadata->getName()) + ); + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->writeln(Json::encode($monorepoComposerJson[$autoloadKey] ?? [], true)); + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->writeln(sprintf( + 'Merge project ("%s")', + $mergeProjectMetadata->getName() + )); + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->writeln(Json::encode($mergeComposerJson[$autoloadKey] ?? [], true)); + + $this->symfonyStyle->newLine(); + } + } + + if ($hasDifference === false) { + $this->symfonyStyle->success('Autoloads are identical, nothing spotted'); + } + } +} diff --git a/src/Macro/Comparator/ConfigFilesComparator.php b/src/Macro/Comparator/ConfigFilesComparator.php new file mode 100644 index 0000000..33a2af9 --- /dev/null +++ b/src/Macro/Comparator/ConfigFilesComparator.php @@ -0,0 +1,73 @@ +getConfigDirectory(); + $mergeConfigDirectory = $mergeProjectMetadata->getConfigDirectory(); + + $this->symfonyStyle->title(sprintf('%d. Comparing config directories', $step)); + + if ($monorepoConfigDirectory !== $mergeConfigDirectory) { + $this->symfonyStyle->warning('Projects have different /config directory location'); + + $this->symfonyStyle->writeln(sprintf( + '%s: %s', + $monorepoProjectMetadata->getName(), + $monorepoProjectMetadata->getConfigDirectory(), + )); + + $this->symfonyStyle->writeln(sprintf( + '%s: %s', + $mergeProjectMetadata->getName(), + $mergeProjectMetadata->getConfigDirectory(), + )); + } + + // render config structures + if (is_string($monorepoConfigDirectory) && is_string($mergeConfigDirectory)) { + $commonConfigFiles = array_intersect( + $monorepoProjectMetadata->getConfigFiles(), + $mergeProjectMetadata->getConfigFiles() + ); + + // find shared items + $extraMonorepoConfigFiles = array_diff($monorepoProjectMetadata->getConfigFiles(), $commonConfigFiles); + + $extraMergeConfigFiles = array_diff($mergeProjectMetadata->getConfigFiles(), $commonConfigFiles); + + $this->symfonyStyle->writeln(sprintf( + 'Extra monorepo project config files ("%s"):', + $monorepoProjectMetadata->getName() + )); + $this->symfonyStyle->newLine(); + $this->symfonyStyle->listing($extraMonorepoConfigFiles); + $this->symfonyStyle->newLine(); + + $this->symfonyStyle->writeln(sprintf( + 'Extra merge project config files ("%s"):', + $mergeProjectMetadata->getName() + )); + $this->symfonyStyle->newLine(); + $this->symfonyStyle->listing($extraMergeConfigFiles); + } + } +} diff --git a/src/Macro/Comparator/MutuallyMissingPackagesComparator.php b/src/Macro/Comparator/MutuallyMissingPackagesComparator.php new file mode 100644 index 0000000..f70a7d0 --- /dev/null +++ b/src/Macro/Comparator/MutuallyMissingPackagesComparator.php @@ -0,0 +1,69 @@ +symfonyStyle->title(sprintf('%d. Composer dependencies', $step)); + + $hasDifference = false; + + foreach (self::REQUIRE_KEYS as $requireKey) { + $monorepoComposerJson = $monorepoProjectMetadata->getComposerJson(); + $mergeComposerJson = $mergeProjectMetadata->getComposerJson(); + + $monorepoRequiredPackages = array_keys($monorepoComposerJson[$requireKey] ?? []); + $mergeRequiredPackages = array_keys($mergeComposerJson[$requireKey] ?? []); + + $missingPackages = array_diff($mergeRequiredPackages, $monorepoRequiredPackages); + sort($missingPackages); + + if ($missingPackages === []) { + continue; + } + + $hasDifference = true; + + $this->symfonyStyle->writeln(sprintf( + 'Monorepo project missing couple dependencies in "%s":', + $requireKey + )); + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->listing($missingPackages); + $this->symfonyStyle->writeln('Add them via this command to the monorepo project:'); + $this->symfonyStyle->writeln(sprintf( + 'composer require %s %s', + implode(' ', $missingPackages), + $requireKey === 'require-dev' ? '--dev' : '' + )); + + $this->symfonyStyle->newLine(); + } + + if ($hasDifference === false) { + $this->symfonyStyle->success('Monorepo project has all required dependencies from the merge project'); + } + } +} diff --git a/src/Macro/Comparator/PHPStanExtensionsComparator.php b/src/Macro/Comparator/PHPStanExtensionsComparator.php new file mode 100644 index 0000000..ff8f09b --- /dev/null +++ b/src/Macro/Comparator/PHPStanExtensionsComparator.php @@ -0,0 +1,64 @@ +symfonyStyle->title(sprintf('%d) PHPStan extensions differences', $step)); + + $monorepoPHPStanPackageNames = $monorepoProjectMetadata->getPackagesMatchingName('phpstan'); + $mergePHPStanPackageNames = $mergeProjectMetadata->getPackagesMatchingName('phpstan'); + + $monorepoExtraPackages = array_diff($monorepoPHPStanPackageNames, $mergePHPStanPackageNames); + $mergeExtraPackages = array_diff($mergePHPStanPackageNames, $monorepoPHPStanPackageNames); + + if ($monorepoExtraPackages === [] && $mergeExtraPackages === []) { + $this->symfonyStyle->success('No differences in PHPStan extensions found'); + return; + } + + if ($monorepoExtraPackages !== []) { + $title = sprintf( + 'Extra PHPStan extensions in monorepo project ("%s"):', + $monorepoProjectMetadata->getName() + ); + $this->renderPackages($title, $monorepoExtraPackages); + } + + if ($mergeExtraPackages !== []) { + $title = sprintf('Extra PHPStan extensions in merge project ("%s"):', $mergeProjectMetadata->getName()); + $this->renderPackages($title, $mergeExtraPackages); + } + } + + /** + * @param string[] $extraPackages + */ + private function renderPackages(string $title, array $extraPackages): void + { + $this->symfonyStyle->writeln($title); + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->listing($extraPackages); + + $this->symfonyStyle->writeln('Add them via this command to the monorepo project:'); + $this->symfonyStyle->writeln('composer require --dev ' . implode(' ', $extraPackages)); + $this->symfonyStyle->newLine(); + } +} diff --git a/src/Macro/Comparator/PHPStanPathsComparator.php b/src/Macro/Comparator/PHPStanPathsComparator.php new file mode 100644 index 0000000..1bf2229 --- /dev/null +++ b/src/Macro/Comparator/PHPStanPathsComparator.php @@ -0,0 +1,75 @@ +symfonyStyle->title(sprintf('%d) PHPStan paths differences', $step)); + + $monorepoPHPStanConfig = $monorepoProjectMetadata->getPHPStanConfig(); + $mergePHPStanConfig = $mergeProjectMetadata->getPHPStanConfig(); + + $monorepoPaths = $monorepoPHPStanConfig['parameters']['paths'] ?? []; + $mergePaths = $mergePHPStanConfig['parameters']['paths'] ?? []; + + if ($monorepoPaths === [] || $mergePaths === []) { + $this->symfonyStyle->writeln( + 'One of the projects does not have PHPStan paths configured. Unable to compare' + ); + return; + } + + $monorepoExtraPaths = array_diff($monorepoPaths, $mergePaths); + $mergeExtraPaths = array_diff($mergePaths, $monorepoPaths); + + if ($monorepoExtraPaths === [] && $mergeExtraPaths === []) { + $this->symfonyStyle->success('No differences in PHPStan paths found'); + return; + } + + if ($monorepoExtraPaths !== []) { + $title = sprintf( + 'Extra PHPStan paths in monorepo project ("%s"):', + $monorepoProjectMetadata->getName() + ); + $this->renderPaths($title, $monorepoExtraPaths); + } + + if ($mergeExtraPaths !== []) { + $title = sprintf( + 'Extra PHPStan paths in merge project ("%s"):', + $mergeProjectMetadata->getName() + ); + $this->renderPaths($title, $mergeExtraPaths); + } + } + + /** + * @param string[] $extraPaths + */ + private function renderPaths(string $title, array $extraPaths): void + { + $this->symfonyStyle->writeln($title); + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->listing($extraPaths); + + $this->symfonyStyle->newLine(); + } +} diff --git a/src/Macro/Contract/ComparatorInterface.php b/src/Macro/Contract/ComparatorInterface.php new file mode 100644 index 0000000..f81a0e4 --- /dev/null +++ b/src/Macro/Contract/ComparatorInterface.php @@ -0,0 +1,16 @@ +singleton( + SymfonyStyle::class, + static function (): SymfonyStyle { + // use null output ofr tests to avoid printing + $consoleOutput = defined('PHPUNIT_COMPOSER_INSTALL') ? new NullOutput() : new ConsoleOutput(); + return new SymfonyStyle(new ArrayInput([]), $consoleOutput); + } + ); + + $container->singleton(Application::class, function (Container $container): Application { + /** @var CompareProjectsCommand $addTypesCommand */ + $addTypesCommand = $container->make(CompareProjectsCommand::class); + + $application = new Application(); + $application->add($addTypesCommand); + + $this->hideDefaultCommands($application); + + return $application; + }); + + return $container; + } + + /** + * @see https://tomasvotruba.com/blog/how-make-your-tool-commands-list-easy-to-read + */ + private function hideDefaultCommands(Application $application): void + { + $application->get('completion') + ->setHidden(); + $application->get('help') + ->setHidden(); + } +} diff --git a/src/Macro/Utils/ArrayUtils.php b/src/Macro/Utils/ArrayUtils.php new file mode 100644 index 0000000..ca89cab --- /dev/null +++ b/src/Macro/Utils/ArrayUtils.php @@ -0,0 +1,43 @@ + $value) { + if (! array_key_exists($key, $array2)) { + $difference[$key] = $value; + } elseif (is_array($value) && is_array($array2[$key])) { + $nestedDiff = self::arrayDiffRecursive($value, $array2[$key]); + if ($nestedDiff !== []) { + $difference[$key] = $nestedDiff; + } + } elseif ($value !== $array2[$key]) { + $difference[$key] = $value; + } + } + + return $difference; + } +} diff --git a/src/Macro/Utils/JsonLoader.php b/src/Macro/Utils/JsonLoader.php new file mode 100644 index 0000000..9b5c8b7 --- /dev/null +++ b/src/Macro/Utils/JsonLoader.php @@ -0,0 +1,26 @@ + + */ + public static function loadFileToJson(string $filePath): array + { + Assert::fileExists($filePath); + $fileContents = FileSystem::read($filePath); + + $json = Json::decode($fileContents, forceArrays: true); + Assert::isArray($json); + + return $json; + } +} diff --git a/src/Macro/ValueObject/ProjectMetadata.php b/src/Macro/ValueObject/ProjectMetadata.php new file mode 100644 index 0000000..3cfbcaa --- /dev/null +++ b/src/Macro/ValueObject/ProjectMetadata.php @@ -0,0 +1,118 @@ + + */ + public function getComposerJson(): array + { + return JsonLoader::loadFileToJson($this->projectDirectory . '/composer.json'); + } + + /** + * @return string[] + */ + public function getPackagesMatchingName(string $matchingName): array + { + $composerJson = $this->getComposerJson(); + + $requirePackages = array_merge($composerJson['require'] ?? [], $composerJson['require-dev'] ?? []); + + $matchingPackageNames = []; + foreach (array_keys($requirePackages) as $packageName) { + if (! str_contains((string) $packageName, $matchingName)) { + continue; + } + + $matchingPackageNames[] = $packageName; + } + + Assert::allString($matchingPackageNames); + + return $matchingPackageNames; + } + + public function getName(): string + { + return (string) Strings::after($this->projectDirectory, '/', -1); + } + + public function getConfigDirectory(): ?string + { + $configFinder = Finder::create()->directories()->name('config') + ->in($this->projectDirectory) + ->depth(' <= 2') + ->notPath('vendor'); + + foreach ($configFinder as $configDirectoryInfo) { + return $configDirectoryInfo->getRelativePathname(); + } + + return null; + } + + /** + * @return string[] + */ + public function getConfigFiles(): array + { + $configDirectory = $this->getConfigDirectory(); + Assert::string($configDirectory); + + $configFilesFinder = Finder::create()->files() + ->name('*.yaml') + ->name('*.yml') + ->name('*.php') + ->in($this->projectDirectory . '/' . $configDirectory) + ->sortByName(); + + $configFiles = []; + foreach ($configFilesFinder as $configFileInfo) { + $configFiles[] = $configFileInfo->getRelativePathname(); + } + + return $configFiles; + } + + /** + * @return mixed[]|null + */ + public function getPHPStanConfig(): ?array + { + $phpstanConfigPath = $this->projectDirectory . '/phpstan.neon'; + if (! file_exists($phpstanConfigPath)) { + return null; + } + + try { + return Neon::decodeFile($phpstanConfigPath); + } catch (\Throwable $throwable) { + // give more context about the file path and its contents + throw new \RuntimeException(sprintf( + 'Failed to decode NEON file "%s" with contents:%s%s %s', + $phpstanConfigPath, + PHP_EOL, + FileSystem::read($phpstanConfigPath), + $throwable->getMessage() + ), 0, $throwable); + } + } +}