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);
+ }
+ }
+}