From 3ee7719acb1af3a329696db345f363305ec61e0b Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Mon, 13 Dec 2021 11:37:07 -0500 Subject: [PATCH] Merge-powered recipe update system --- .php_cs.dist => .php-cs-fixer.dist.php | 2 +- src/Command/InstallRecipesCommand.php | 6 +- src/Command/RecipesCommand.php | 73 +-- src/Command/UpdateRecipesCommand.php | 423 +++++++++++++ src/Configurator.php | 14 + src/Configurator/AbstractConfigurator.php | 43 +- src/Configurator/BundlesConfigurator.php | 98 ++- .../ComposerScriptsConfigurator.php | 33 +- src/Configurator/ContainerConfigurator.php | 76 ++- .../CopyFromPackageConfigurator.php | 87 ++- .../CopyFromRecipeConfigurator.php | 23 +- .../DockerComposeConfigurator.php | 208 ++++-- src/Configurator/DockerfileConfigurator.php | 84 ++- src/Configurator/EnvConfigurator.php | 103 ++- src/Configurator/GitignoreConfigurator.php | 67 +- src/Configurator/MakefileConfigurator.php | 71 ++- src/Downloader.php | 23 +- src/Flex.php | 30 +- src/GithubApi.php | 204 ++++++ src/InformationOperation.php | 23 + src/PackageJsonSynchronizer.php | 15 +- src/Recipe.php | 5 + src/Update/DiffHelper.php | 40 ++ src/Update/RecipePatch.php | 45 ++ src/Update/RecipePatcher.php | 226 +++++++ src/Update/RecipeUpdate.php | 114 ++++ tests/Command/UpdateRecipesCommandTest.php | 120 ++++ .../Configurator/BundlesConfiguratorTest.php | 122 +++- .../ComposerScriptsConfiguratorTest.php | 213 +++++++ .../ContainerConfiguratorTest.php | 74 +++ ...pyDirectoryFromPackageConfiguratorTest.php | 43 ++ .../CopyFromPackageConfiguratorTest.php | 3 +- .../CopyFromRecipeConfiguratorTest.php | 58 +- .../DockerComposeConfiguratorTest.php | 179 +++++- .../DockerfileConfiguratorTest.php | 119 +++- tests/Configurator/EnvConfiguratorTest.php | 74 +++ .../GitignoreConfiguratorTest.php | 85 +++ .../Configurator/MakefileConfiguratorTest.php | 106 ++++ tests/Fixtures/update_recipes/composer.json | 13 + tests/Fixtures/update_recipes/console | 43 ++ tests/Fixtures/update_recipes/symfony.lock | 14 + tests/Update/DiffHelperTest.php | 331 ++++++++++ tests/Update/RecipePatchTest.php | 31 + tests/Update/RecipePatcherTest.php | 593 ++++++++++++++++++ tests/Update/RecipeUpdateTest.php | 71 +++ 45 files changed, 4141 insertions(+), 287 deletions(-) rename .php_cs.dist => .php-cs-fixer.dist.php (94%) create mode 100644 src/Command/UpdateRecipesCommand.php create mode 100644 src/GithubApi.php create mode 100644 src/Update/DiffHelper.php create mode 100644 src/Update/RecipePatch.php create mode 100644 src/Update/RecipePatcher.php create mode 100644 src/Update/RecipeUpdate.php create mode 100644 tests/Command/UpdateRecipesCommandTest.php create mode 100644 tests/Configurator/ComposerScriptsConfiguratorTest.php create mode 100644 tests/Fixtures/update_recipes/composer.json create mode 100644 tests/Fixtures/update_recipes/console create mode 100644 tests/Fixtures/update_recipes/symfony.lock create mode 100644 tests/Update/DiffHelperTest.php create mode 100644 tests/Update/RecipePatchTest.php create mode 100644 tests/Update/RecipePatcherTest.php create mode 100644 tests/Update/RecipeUpdateTest.php diff --git a/.php_cs.dist b/.php-cs-fixer.dist.php similarity index 94% rename from .php_cs.dist rename to .php-cs-fixer.dist.php index d321fcde1..e1a10d362 100644 --- a/.php_cs.dist +++ b/.php-cs-fixer.dist.php @@ -2,7 +2,7 @@ $finder = PhpCsFixer\Finder::create()->in(__DIR__); -return PhpCsFixer\Config::create() +return (new PhpCsFixer\Config()) ->setFinder($finder) ->setRules(array( '@Symfony' => true, diff --git a/src/Command/InstallRecipesCommand.php b/src/Command/InstallRecipesCommand.php index 7e6ad0ce3..de6e36f01 100644 --- a/src/Command/InstallRecipesCommand.php +++ b/src/Command/InstallRecipesCommand.php @@ -13,17 +13,17 @@ use Composer\Command\BaseCommand; use Composer\DependencyResolver\Operation\InstallOperation; -use Composer\Factory; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Flex\Event\UpdateEvent; -use Symfony\Flex\Lock; +use Symfony\Flex\Flex; class InstallRecipesCommand extends BaseCommand { + /** @var Flex */ private $flex; private $rootDir; @@ -55,7 +55,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int throw new RuntimeException('Cannot run "sync-recipes --force": git not found.'); } - $symfonyLock = new Lock(getenv('SYMFONY_LOCKFILE') ?: str_replace('composer.json', 'symfony.lock', Factory::getComposerFile())); + $symfonyLock = $this->flex->getLock(); $composer = $this->getComposer(); $locker = $composer->getLocker(); $lockData = $locker->getLockData(); diff --git a/src/Command/RecipesCommand.php b/src/Command/RecipesCommand.php index 1d65648c7..2aaf88e31 100644 --- a/src/Command/RecipesCommand.php +++ b/src/Command/RecipesCommand.php @@ -13,11 +13,11 @@ use Composer\Command\BaseCommand; use Composer\Downloader\TransportException; -use Composer\Util\HttpDownloader; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Flex\GithubApi; use Symfony\Flex\InformationOperation; use Symfony\Flex\Lock; use Symfony\Flex\Recipe; @@ -31,13 +31,13 @@ class RecipesCommand extends BaseCommand private $flex; private $symfonyLock; - private $downloader; + private $githubApi; public function __construct(/* cannot be type-hinted */ $flex, Lock $symfonyLock, $downloader) { $this->flex = $flex; $this->symfonyLock = $symfonyLock; - $this->downloader = $downloader; + $this->githubApi = new GithubApi($downloader); parent::__construct(); } @@ -136,7 +136,7 @@ protected function execute(InputInterface $input, OutputInterface $output) '', 'Run:', ' * composer recipes vendor/package to see details about a recipe.', - ' * composer recipes:install vendor/package --force -v to update that recipe.', + ' * composer recipes:update vendor/package to update that recipe.', '', ])); @@ -171,13 +171,15 @@ private function displayPackageInformation(Recipe $recipe) $commitDate = null; if (null !== $lockRef && null !== $lockRepo) { try { - list($gitSha, $commitDate) = $this->findRecipeCommitDataFromTreeRef( + $recipeCommitData = $this->githubApi->findRecipeCommitDataFromTreeRef( $recipe->getName(), $lockRepo, $lockBranch ?? '', $lockVersion, $lockRef ); + $gitSha = $recipeCommitData ? $recipeCommitData['commit'] : null; + $commitDate = $recipeCommitData ? $recipeCommitData['date'] : null; } catch (TransportException $exception) { $io->writeError('Error downloading exact git sha for installed recipe.'); } @@ -232,7 +234,7 @@ private function displayPackageInformation(Recipe $recipe) $io->write([ '', 'Update this recipe by running:', - sprintf('composer recipes:install %s --force -v', $recipe->getName()), + sprintf('composer recipes:update %s', $recipe->getName()), ]); } } @@ -324,63 +326,4 @@ private function writeTreeLine($line) $io->write($line); } - - /** - * Attempts to find the original git sha when the recipe was installed. - */ - private function findRecipeCommitDataFromTreeRef(string $package, string $repo, string $branch, string $version, string $lockRef) - { - // only supports public repository placement - if (0 !== strpos($repo, 'github.com')) { - return [null, null]; - } - - $parts = explode('/', $repo); - if (3 !== \count($parts)) { - return [null, null]; - } - - $recipePath = sprintf('%s/%s', $package, $version); - $commitsData = $this->requestGitHubApi(sprintf( - 'https://api.github.com/repos/%s/%s/commits?path=%s&sha=%s', - $parts[1], - $parts[2], - $recipePath, - $branch - )); - - foreach ($commitsData as $commitData) { - // go back the commits one-by-one - $treeUrl = $commitData['commit']['tree']['url'].'?recursive=true'; - - // fetch the full tree, then look for the tree for the package path - $treeData = $this->requestGitHubApi($treeUrl); - foreach ($treeData['tree'] as $treeItem) { - if ($treeItem['path'] !== $recipePath) { - continue; - } - - if ($treeItem['sha'] === $lockRef) { - // shorten for brevity - return [ - substr($commitData['sha'], 0, 7), - $commitData['commit']['committer']['date'], - ]; - } - } - } - - return [null, null]; - } - - private function requestGitHubApi(string $path) - { - if ($this->downloader instanceof HttpDownloader) { - $contents = $this->downloader->get($path)->getBody(); - } else { - $contents = $this->downloader->getContents('api.github.com', $path, false); - } - - return json_decode($contents, true); - } } diff --git a/src/Command/UpdateRecipesCommand.php b/src/Command/UpdateRecipesCommand.php new file mode 100644 index 000000000..9a663cc7c --- /dev/null +++ b/src/Command/UpdateRecipesCommand.php @@ -0,0 +1,423 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Command; + +use Composer\Command\BaseCommand; +use Composer\IO\IOInterface; +use Composer\Util\ProcessExecutor; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Flex\Configurator; +use Symfony\Flex\Downloader; +use Symfony\Flex\Flex; +use Symfony\Flex\GithubApi; +use Symfony\Flex\InformationOperation; +use Symfony\Flex\Lock; +use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipePatcher; +use Symfony\Flex\Update\RecipeUpdate; + +class UpdateRecipesCommand extends BaseCommand +{ + /** @var Flex */ + private $flex; + private $downloader; + private $configurator; + private $rootDir; + private $githubApi; + private $processExecutor; + + public function __construct(/* cannot be type-hinted */ $flex, Downloader $downloader, $httpDownloader, Configurator $configurator, string $rootDir) + { + $this->flex = $flex; + $this->downloader = $downloader; + $this->configurator = $configurator; + $this->rootDir = $rootDir; + $this->githubApi = new GithubApi($httpDownloader); + + parent::__construct(); + } + + protected function configure() + { + $this->setName('symfony:recipes:update') + ->setAliases(['recipes:update']) + ->setDescription('Updates an already-installed recipe to the latest version.') + ->addArgument('package', InputArgument::OPTIONAL, 'Recipe that should be updated.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $win = '\\' === \DIRECTORY_SEPARATOR; + $runtimeExceptionClass = class_exists(RuntimeException::class) ? RuntimeException::class : \RuntimeException::class; + if (!@is_executable(strtok(exec($win ? 'where git' : 'command -v git'), \PHP_EOL))) { + throw new $runtimeExceptionClass('Cannot run "recipes:update": git not found.'); + } + + $io = $this->getIO(); + if (!$this->isIndexClean($io)) { + $io->write([ + ' Cannot run recipes:update: Your git index contains uncommitted changes.', + ' Please commit or stash them and try again!', + ]); + + return 1; + } + + $packageName = $input->getArgument('package'); + $symfonyLock = $this->flex->getLock(); + if (!$packageName) { + $packageName = $this->askForPackage($io, $symfonyLock); + + if (null === $packageName) { + $io->writeError('All packages appear to be up-to-date!'); + + return 0; + } + } + + if (!$symfonyLock->has($packageName)) { + $io->writeError([ + 'Package not found inside symfony.lock. It looks like it\'s not installed?', + sprintf('Try running composer recipes:install %s --force -v to re-install the recipe.', $packageName), + ]); + + return 1; + } + + $packageLockData = $symfonyLock->get($packageName); + if (!isset($packageLockData['recipe'])) { + $io->writeError([ + 'It doesn\'t look like this package had a recipe when it was originally installed.', + 'To install the latest version of the recipe, if there is one, run:', + sprintf(' composer recipes:install %s --force -v', $packageName), + ]); + + return 1; + } + + $recipeRef = $packageLockData['recipe']['ref'] ?? null; + $recipeVersion = $packageLockData['recipe']['version'] ?? null; + if (!$recipeRef || !$recipeVersion) { + $io->writeError([ + 'The version of the installed recipe was not saved into symfony.lock.', + 'This is possible if it was installed by an old version of Symfony Flex.', + 'Update the recipe by re-installing the latest version with:', + sprintf(' composer recipes:install %s --force -v', $packageName), + ]); + + return 1; + } + + $originalRecipe = $this->getRecipe($packageName, $recipeRef, $recipeVersion); + + if (null === $originalRecipe) { + $io->writeError([ + 'The original recipe version you have installed could not be found, it may be too old.', + 'Update the recipe by re-installing the latest version with:', + sprintf(' composer recipes:install %s --force -v', $packageName), + ]); + + return 1; + } + + $newRecipe = $this->getRecipe($packageName); + + if ($newRecipe->getRef() === $originalRecipe->getRef()) { + $io->write(sprintf('This recipe for %s is already at the latest version.', $packageName)); + + return 0; + } + + $io->write([ + sprintf(' Updating recipe for %s...', $packageName), + '', + ]); + + $recipeUpdate = new RecipeUpdate($originalRecipe, $newRecipe, $symfonyLock, $this->rootDir); + $this->configurator->populateUpdate($recipeUpdate); + $originalComposerJsonHash = $this->flex->getComposerJsonHash(); + $patcher = new RecipePatcher($this->rootDir, $io); + + try { + $patch = $patcher->generatePatch($recipeUpdate->getOriginalFiles(), $recipeUpdate->getNewFiles()); + $hasConflicts = !$patcher->applyPatch($patch); + } catch (\Throwable $throwable) { + $io->writeError([ + 'There was an error applying the recipe update patch', + $throwable->getMessage(), + '', + 'Update the recipe by re-installing the latest version with:', + sprintf(' composer recipes:install %s --force -v', $packageName), + ]); + + return 1; + } + + $symfonyLock->add($packageName, $newRecipe->getLock()); + $this->flex->finish($this->rootDir, $originalComposerJsonHash); + + // stage symfony.lock, as all patched files with already be staged + $cmdOutput = ''; + $this->getProcessExecutor()->execute('git add symfony.lock', $cmdOutput, $this->rootDir); + + $io->write([ + ' ', + ' Yes! Recipe updated! ', + ' ', + '', + ]); + + if ($hasConflicts) { + $io->write([ + ' The recipe was updated but with one or more conflicts.', + ' Run git status to see them.', + ' After resolving, commit your changes like normal.', + ]); + } else { + if (!$patch->getPatch()) { + // no changes were required + $io->write([ + ' No files were changed as a result of the update.', + ]); + } else { + $io->write([ + ' Run git status or git diff --cached to see the changes.', + ' When you\'re ready, commit these changes like normal.', + ]); + } + } + + if (0 !== \count($recipeUpdate->getCopyFromPackagePaths())) { + $io->write([ + '', + ' NOTE:', + ' This recipe copies the following paths from the bundle into your app:', + ]); + foreach ($recipeUpdate->getCopyFromPackagePaths() as $source => $target) { + $io->write(sprintf(' * %s => %s', $source, $target)); + } + $io->write([ + '', + ' The recipe updater has no way of knowing if these files have changed since you originally installed the recipe.', + ' And so, no updates were made to these paths.', + ]); + } + + if (0 !== \count($patch->getRemovedPatches())) { + if (1 === \count($patch->getRemovedPatches())) { + $notes = [ + sprintf(' The file %s was not updated because it doesn\'t exist in your app.', array_keys($patch->getRemovedPatches())[0]), + ]; + } else { + $notes = [' The following files were not updated because they don\'t exist in your app:']; + foreach ($patch->getRemovedPatches() as $filename => $contents) { + $notes[] = sprintf(' * %s', $filename); + } + } + $io->write([ + '', + ' NOTE:', + ]); + $io->write($notes); + $io->write(''); + if ($io->askConfirmation(' Would you like to save the "diff" to a file so you can review it? (Y/n) ')) { + $patchFilename = str_replace('/', '.', $packageName).'.updates-for-deleted-files.patch'; + file_put_contents($this->rootDir.'/'.$patchFilename, implode("\n", $patch->getRemovedPatches())); + $io->write([ + '', + sprintf(' Saved diff to %s', $patchFilename), + ]); + } + } + + if ($patch->getPatch()) { + $io->write(''); + $io->write(' Calculating CHANGELOG...', false); + $changelog = $this->generateChangelog($originalRecipe); + $io->write("\r", false); // clear current line + if ($changelog) { + $io->write($changelog); + } else { + $io->write('No CHANGELOG could be calculated.'); + } + } + + return 0; + } + + private function getRecipe(string $packageName, string $recipeRef = null, string $recipeVersion = null): ?Recipe + { + $installedRepo = $this->getComposer()->getRepositoryManager()->getLocalRepository(); + $package = $installedRepo->findPackage($packageName, '*'); + if (null === $package) { + throw new RuntimeException(sprintf('Could not find package "%s". Try running "composer install".', $packageName)); + } + $operation = new InformationOperation($package); + if (null !== $recipeRef) { + $operation->setSpecificRecipeVersion($recipeRef, $recipeVersion); + } + $recipes = $this->downloader->getRecipes([$operation]); + + if (0 === \count($recipes['manifests'] ?? [])) { + return null; + } + + return new Recipe( + $package, + $packageName, + $operation->getOperationType(), + $recipes['manifests'][$packageName], + $recipes['locks'][$packageName] ?? [] + ); + } + + private function generateChangelog(Recipe $originalRecipe): ?array + { + $recipeData = $originalRecipe->getLock()['recipe'] ?? null; + if (null === $recipeData) { + return null; + } + + if (!isset($recipeData['ref']) || !isset($recipeData['repo']) || !isset($recipeData['branch']) || !isset($recipeData['version'])) { + return null; + } + + $currentRecipeVersionData = $this->githubApi->findRecipeCommitDataFromTreeRef( + $originalRecipe->getName(), + $recipeData['repo'], + $recipeData['branch'], + $recipeData['version'], + $recipeData['ref'] + ); + + if (!$currentRecipeVersionData) { + return null; + } + + $recipeVersions = $this->githubApi->getVersionsOfRecipe( + $recipeData['repo'], + $recipeData['branch'], + $originalRecipe->getName() + ); + if (!$recipeVersions) { + return null; + } + + $newerRecipeVersions = array_filter($recipeVersions, function ($version) use ($recipeData) { + return version_compare($version, $recipeData['version'], '>'); + }); + + $newCommits = $currentRecipeVersionData['new_commits']; + foreach ($newerRecipeVersions as $newerRecipeVersion) { + $newCommits = array_merge( + $newCommits, + $this->githubApi->getCommitDataForPath($recipeData['repo'], $originalRecipe->getName().'/'.$newerRecipeVersion, $recipeData['branch']) + ); + } + + $newCommits = array_unique($newCommits); + asort($newCommits); + + $pullRequests = []; + foreach ($newCommits as $commit => $date) { + $pr = $this->githubApi->getPullRequestForCommit($commit, $recipeData['repo']); + if ($pr) { + $pullRequests[$pr['number']] = $pr; + } + } + + $lines = []; + // borrowed from symfony/console's OutputFormatterStyle + $handlesHrefGracefully = 'JetBrains-JediTerm' !== getenv('TERMINAL_EMULATOR') + && (!getenv('KONSOLE_VERSION') || (int) getenv('KONSOLE_VERSION') > 201100); + foreach ($pullRequests as $number => $data) { + $url = $data['url']; + if ($handlesHrefGracefully) { + $url = "\033]8;;$url\033\\$number\033]8;;\033\\"; + } + $lines[] = sprintf(' * %s (PR %s)', $data['title'], $url); + } + + return $lines; + } + + private function askForPackage(IOInterface $io, Lock $symfonyLock): ?string + { + $installedRepo = $this->getComposer()->getRepositoryManager()->getLocalRepository(); + $locker = $this->getComposer()->getLocker(); + $lockData = $locker->getLockData(); + + // Merge all packages installed + $packages = array_merge($lockData['packages'], $lockData['packages-dev']); + + $operations = []; + foreach ($packages as $value) { + if (null === $pkg = $installedRepo->findPackage($value['name'], '*')) { + continue; + } + + $operations[] = new InformationOperation($pkg); + } + + $recipes = $this->flex->fetchRecipes($operations, false); + ksort($recipes); + + $outdatedRecipes = []; + foreach ($recipes as $name => $recipe) { + $lockRef = $symfonyLock->get($name)['recipe']['ref'] ?? null; + + if (null !== $lockRef && $recipe->getRef() !== $lockRef && !$recipe->isAuto()) { + $outdatedRecipes[] = $name; + } + } + + if (0 === \count($outdatedRecipes)) { + return null; + } + + $question = 'Which outdated recipe would you like to update? (default: 0)'; + + $choice = $io->select( + $question, + $outdatedRecipes, + 0 + ); + + return $outdatedRecipes[$choice]; + } + + private function isIndexClean(IOInterface $io): bool + { + $output = ''; + + $this->getProcessExecutor()->execute('git status --porcelain --untracked-files=no', $output, $this->rootDir); + if ('' !== trim($output)) { + return false; + } + + return true; + } + + private function getProcessExecutor(): ProcessExecutor + { + if (null === $this->processExecutor) { + $this->processExecutor = new ProcessExecutor($this->getIO()); + } + + return $this->processExecutor; + } +} diff --git a/src/Configurator.php b/src/Configurator.php index 80a83b1ab..da957f0bd 100644 --- a/src/Configurator.php +++ b/src/Configurator.php @@ -14,6 +14,7 @@ use Composer\Composer; use Composer\IO\IOInterface; use Symfony\Flex\Configurator\AbstractConfigurator; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -56,6 +57,19 @@ public function install(Recipe $recipe, Lock $lock, array $options = []) } } + public function populateUpdate(RecipeUpdate $recipeUpdate): void + { + $originalManifest = $recipeUpdate->getOriginalRecipe()->getManifest(); + $newManifest = $recipeUpdate->getNewRecipe()->getManifest(); + foreach (array_keys($this->configurators) as $key) { + if (!isset($originalManifest[$key]) && !isset($newManifest[$key])) { + continue; + } + + $this->get($key)->update($recipeUpdate, $originalManifest[$key] ?? [], $newManifest[$key] ?? []); + } + } + public function unconfigure(Recipe $recipe, Lock $lock) { $manifest = $recipe->getManifest(); diff --git a/src/Configurator/AbstractConfigurator.php b/src/Configurator/AbstractConfigurator.php index ec05dacf0..26711fcc0 100644 --- a/src/Configurator/AbstractConfigurator.php +++ b/src/Configurator/AbstractConfigurator.php @@ -17,6 +17,7 @@ use Symfony\Flex\Options; use Symfony\Flex\Path; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -40,6 +41,8 @@ abstract public function configure(Recipe $recipe, $config, Lock $lock, array $o abstract public function unconfigure(Recipe $recipe, $config, Lock $lock); + abstract public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void; + protected function write($messages) { if (!\is_array($messages)) { @@ -80,19 +83,49 @@ protected function updateData(string $file, string $data): bool return false; } + $contents = file_get_contents($file); + + $newContents = $this->updateDataString($contents, $data); + if (null === $newContents) { + return false; + } + + file_put_contents($file, $newContents); + + return true; + } + + /** + * @return string|null returns the updated content if the section was found, null if not found + */ + protected function updateDataString(string $contents, string $data): ?string + { $pieces = explode("\n", trim($data)); $startMark = trim(reset($pieces)); $endMark = trim(end($pieces)); - $contents = file_get_contents($file); if (false === strpos($contents, $startMark) || false === strpos($contents, $endMark)) { - return false; + return null; } $pattern = '/'.preg_quote($startMark, '/').'.*?'.preg_quote($endMark, '/').'/s'; - $newContents = preg_replace($pattern, trim($data), $contents); - file_put_contents($file, $newContents); - return true; + return preg_replace($pattern, trim($data), $contents); + } + + protected function extractSection(Recipe $recipe, string $contents): ?string + { + $section = $this->markData($recipe, '----'); + + $pieces = explode("\n", trim($section)); + $startMark = trim(reset($pieces)); + $endMark = trim(end($pieces)); + + $pattern = '/'.preg_quote($startMark, '/').'.*?'.preg_quote($endMark, '/').'/s'; + + $matches = []; + preg_match($pattern, $contents, $matches); + + return $matches[0] ?? null; } } diff --git a/src/Configurator/BundlesConfigurator.php b/src/Configurator/BundlesConfigurator.php index 677d1322c..877f41be8 100644 --- a/src/Configurator/BundlesConfigurator.php +++ b/src/Configurator/BundlesConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -22,21 +23,8 @@ class BundlesConfigurator extends AbstractConfigurator public function configure(Recipe $recipe, $bundles, Lock $lock, array $options = []) { $this->write('Enabling the package as a Symfony bundle'); - $file = $this->getConfFile(); - $registered = $this->load($file); - $classes = $this->parse($bundles, $registered); - if (isset($classes[$fwb = 'Symfony\Bundle\FrameworkBundle\FrameworkBundle'])) { - foreach ($classes[$fwb] as $env) { - $registered[$fwb][$env] = true; - } - unset($classes[$fwb]); - } - foreach ($classes as $class => $envs) { - foreach ($envs as $env) { - $registered[$class][$env] = true; - } - } - $this->dump($file, $registered); + $registered = $this->configureBundles($bundles); + $this->dump($this->getConfFile(), $registered); } public function unconfigure(Recipe $recipe, $bundles, Lock $lock) @@ -48,21 +36,57 @@ public function unconfigure(Recipe $recipe, $bundles, Lock $lock) } $registered = $this->load($file); - foreach (array_keys($this->parse($bundles, [])) as $class) { + foreach (array_keys($this->prepareBundles($bundles)) as $class) { unset($registered[$class]); } $this->dump($file, $registered); } - private function parse(array $manifest, array $registered): array + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void { - $bundles = []; - foreach ($manifest as $class => $envs) { - if (!isset($registered[$class])) { - $bundles[ltrim($class, '\\')] = $envs; + $originalBundles = $this->configureBundles($originalConfig); + $recipeUpdate->setOriginalFile( + $this->getLocalConfFile(), + $this->buildContents($originalBundles) + ); + + $newBundles = $this->configureBundles($newConfig); + $recipeUpdate->setNewFile( + $this->getLocalConfFile(), + $this->buildContents($newBundles) + ); + } + + private function configureBundles(array $bundles): array + { + $file = $this->getConfFile(); + $registered = $this->load($file); + $classes = $this->prepareBundles($bundles); + if (isset($classes[$fwb = 'Symfony\Bundle\FrameworkBundle\FrameworkBundle'])) { + foreach ($classes[$fwb] as $env) { + $registered[$fwb][$env] = true; + } + unset($classes[$fwb]); + } + foreach ($classes as $class => $envs) { + // if the class already existed, clear so we can update the envs + if (isset($registered[$class])) { + $registered[$class] = []; + } + foreach ($envs as $env) { + $registered[$class][$env] = true; } } + return $registered; + } + + private function prepareBundles(array $bundles): array + { + foreach ($bundles as $class => $envs) { + $bundles[ltrim($class, '\\')] = $envs; + } + return $bundles; } @@ -77,6 +101,21 @@ private function load(string $file): array } private function dump(string $file, array $bundles) + { + $contents = $this->buildContents($bundles); + + if (!is_dir(\dirname($file))) { + mkdir(\dirname($file), 0777, true); + } + + file_put_contents($file, $contents); + + if (\function_exists('opcache_invalidate')) { + opcache_invalidate($file); + } + } + + private function buildContents(array $bundles): string { $contents = " $envs) { @@ -89,19 +128,16 @@ private function dump(string $file, array $bundles) } $contents .= "];\n"; - if (!is_dir(\dirname($file))) { - mkdir(\dirname($file), 0777, true); - } - - file_put_contents($file, $contents); - - if (\function_exists('opcache_invalidate')) { - opcache_invalidate($file); - } + return $contents; } private function getConfFile(): string { - return $this->options->get('root-dir').'/'.$this->options->expandTargetDir('%CONFIG_DIR%/bundles.php'); + return $this->options->get('root-dir').'/'.$this->getLocalConfFile(); + } + + private function getLocalConfFile(): string + { + return $this->options->expandTargetDir('%CONFIG_DIR%/bundles.php'); } } diff --git a/src/Configurator/ComposerScriptsConfigurator.php b/src/Configurator/ComposerScriptsConfigurator.php index 0ce5a7210..abdcefc80 100644 --- a/src/Configurator/ComposerScriptsConfigurator.php +++ b/src/Configurator/ComposerScriptsConfigurator.php @@ -16,6 +16,7 @@ use Composer\Json\JsonManipulator; use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -26,9 +27,18 @@ public function configure(Recipe $recipe, $scripts, Lock $lock, array $options = { $json = new JsonFile(Factory::getComposerFile()); + file_put_contents($json->getPath(), $this->configureScripts($scripts, $json)); + } + + public function unconfigure(Recipe $recipe, $scripts, Lock $lock) + { + $json = new JsonFile(Factory::getComposerFile()); + $jsonContents = $json->read(); $autoScripts = $jsonContents['scripts']['auto-scripts'] ?? []; - $autoScripts = array_merge($autoScripts, $scripts); + foreach (array_keys($scripts) as $cmd) { + unset($autoScripts[$cmd]); + } $manipulator = new JsonManipulator(file_get_contents($json->getPath())); $manipulator->addSubNode('scripts', 'auto-scripts', $autoScripts); @@ -36,19 +46,30 @@ public function configure(Recipe $recipe, $scripts, Lock $lock, array $options = file_put_contents($json->getPath(), $manipulator->getContents()); } - public function unconfigure(Recipe $recipe, $scripts, Lock $lock) + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void { $json = new JsonFile(Factory::getComposerFile()); + $jsonPath = ltrim(str_replace($recipeUpdate->getRootDir(), '', $json->getPath()), '/\\'); + + $recipeUpdate->setOriginalFile( + $jsonPath, + $this->configureScripts($originalConfig, $json) + ); + $recipeUpdate->setNewFile( + $jsonPath, + $this->configureScripts($newConfig, $json) + ); + } + private function configureScripts(array $scripts, JsonFile $json): string + { $jsonContents = $json->read(); $autoScripts = $jsonContents['scripts']['auto-scripts'] ?? []; - foreach (array_keys($scripts) as $cmd) { - unset($autoScripts[$cmd]); - } + $autoScripts = array_merge($autoScripts, $scripts); $manipulator = new JsonManipulator(file_get_contents($json->getPath())); $manipulator->addSubNode('scripts', 'auto-scripts', $autoScripts); - file_put_contents($json->getPath(), $manipulator->getContents()); + return $manipulator->getContents(); } } diff --git a/src/Configurator/ContainerConfigurator.php b/src/Configurator/ContainerConfigurator.php index 0af54aaa6..897c8dbb2 100644 --- a/src/Configurator/ContainerConfigurator.php +++ b/src/Configurator/ContainerConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -22,13 +23,17 @@ class ContainerConfigurator extends AbstractConfigurator public function configure(Recipe $recipe, $parameters, Lock $lock, array $options = []) { $this->write('Setting parameters'); - $this->addParameters($parameters); + $contents = $this->configureParameters($parameters); + + if (null !== $contents) { + file_put_contents($this->options->get('root-dir').'/'.$this->getServicesPath(), $contents); + } } public function unconfigure(Recipe $recipe, $parameters, Lock $lock) { $this->write('Unsetting parameters'); - $target = $this->options->get('root-dir').'/'.$this->options->expandTargetDir('%CONFIG_DIR%/services.yaml'); + $target = $this->options->get('root-dir').'/'.$this->getServicesPath(); $lines = []; foreach (file($target) as $line) { if ($this->removeParameters(1, $parameters, $line)) { @@ -39,9 +44,26 @@ public function unconfigure(Recipe $recipe, $parameters, Lock $lock) file_put_contents($target, implode('', $lines)); } - private function addParameters(array $parameters) + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void { - $target = $this->options->get('root-dir').'/'.$this->options->expandTargetDir('%CONFIG_DIR%/services.yaml'); + if ($originalConfig) { + $recipeUpdate->setOriginalFile( + $this->getServicesPath(), + $this->configureParameters($originalConfig, true) + ); + } + + if ($newConfig) { + $recipeUpdate->setNewFile( + $this->getServicesPath(), + $this->configureParameters($newConfig, true) + ); + } + } + + private function configureParameters(array $parameters, bool $update = false): string + { + $target = $this->options->get('root-dir').'/'.$this->getServicesPath(); $endAt = 0; $isParameters = false; $lines = []; @@ -60,31 +82,36 @@ private function addParameters(array $parameters) continue; } foreach ($parameters as $key => $value) { - if (preg_match(sprintf('/^\s+%s\:/', preg_quote($key, '/')), $line)) { + $matches = []; + if (preg_match(sprintf('/^\s+%s\:/', preg_quote($key, '/')), $line, $matches)) { + if ($update) { + $lines[$i] = substr($line, 0, \strlen($matches[0])).' '.str_replace("'", "''", $value)."\n"; + } + unset($parameters[$key]); } } } - if (!$parameters) { - return; - } - $parametersLines = []; - if (!$endAt) { - $parametersLines[] = "parameters:\n"; - } - foreach ($parameters as $key => $value) { - if (\is_array($value)) { - $parametersLines[] = sprintf(" %s:\n%s", $key, $this->dumpYaml(2, $value)); - continue; + if ($parameters) { + $parametersLines = []; + if (!$endAt) { + $parametersLines[] = "parameters:\n"; } - $parametersLines[] = sprintf(" %s: '%s'%s", $key, str_replace("'", "''", $value), "\n"); - } - if (!$endAt) { - $parametersLines[] = "\n"; + foreach ($parameters as $key => $value) { + if (\is_array($value)) { + $parametersLines[] = sprintf(" %s:\n%s", $key, $this->dumpYaml(2, $value)); + continue; + } + $parametersLines[] = sprintf(" %s: '%s'%s", $key, str_replace("'", "''", $value), "\n"); + } + if (!$endAt) { + $parametersLines[] = "\n"; + } + array_splice($lines, $endAt, 0, $parametersLines); } - array_splice($lines, $endAt, 0, $parametersLines); - file_put_contents($target, implode('', $lines)); + + return implode('', $lines); } private function removeParameters($level, $params, $line) @@ -115,4 +142,9 @@ private function dumpYaml($level, $array): string return $line; } + + private function getServicesPath(): string + { + return $this->options->expandTargetDir('%CONFIG_DIR%/services.yaml'); + } } diff --git a/src/Configurator/CopyFromPackageConfigurator.php b/src/Configurator/CopyFromPackageConfigurator.php index b9702a643..1275dd734 100644 --- a/src/Configurator/CopyFromPackageConfigurator.php +++ b/src/Configurator/CopyFromPackageConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -25,7 +26,10 @@ public function configure(Recipe $recipe, $config, Lock $lock, array $options = $packageDir = $this->composer->getInstallationManager()->getInstallPath($recipe->getPackage()); $options = array_merge($this->options->toArray(), $options); - $this->copyFiles($config, $packageDir, $options); + $files = $this->getFilesToCopy($config, $packageDir); + foreach ($files as $source => $target) { + $this->copyFile($source, $target, $options); + } } public function unconfigure(Recipe $recipe, $config, Lock $lock) @@ -35,23 +39,48 @@ public function unconfigure(Recipe $recipe, $config, Lock $lock) $this->removeFiles($config, $packageDir, $this->options->get('root-dir')); } - private function copyFiles(array $manifest, string $from, array $options) + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void { - $to = $options['root-dir'] ?? '.'; + $packageDir = $this->composer->getInstallationManager()->getInstallPath($recipeUpdate->getNewRecipe()->getPackage()); + foreach ($originalConfig as $source => $target) { + if (isset($newConfig[$source])) { + // path is in both, we cannot update + $recipeUpdate->addCopyFromPackagePath( + $packageDir.'/'.$source, + $this->options->expandTargetDir($target) + ); + + unset($newConfig[$source]); + } + + // if any paths were removed from the recipe, we'll keep them + } + + // any remaining files are new, and we can copy them + foreach ($this->getFilesToCopy($newConfig, $packageDir) as $source => $target) { + if (!file_exists($source)) { + throw new \LogicException(sprintf('File "%s" does not exist!', $source)); + } + + $recipeUpdate->setNewFile($target, file_get_contents($source)); + } + } + + private function getFilesToCopy(array $manifest, string $from): array + { + $files = []; foreach ($manifest as $source => $target) { $target = $this->options->expandTargetDir($target); if ('/' === substr($source, -1)) { - $this->copyDir($this->path->concatenate([$from, $source]), $this->path->concatenate([$to, $target]), $options); - } else { - $targetPath = $this->path->concatenate([$to, $target]); - if (!is_dir(\dirname($targetPath))) { - mkdir(\dirname($targetPath), 0777, true); - $this->write(sprintf(' Created "%s"', $this->path->relativize(\dirname($targetPath)))); - } + $files = array_merge($files, $this->getFilesForDir($this->path->concatenate([$from, $source]), $this->path->concatenate([$target]))); - $this->copyFile($this->path->concatenate([$from, $source]), $targetPath, $options); + continue; } + + $files[$this->path->concatenate([$from, $source])] = $target; } + + return $files; } private function removeFiles(array $manifest, string $from, string $to) @@ -70,30 +99,31 @@ private function removeFiles(array $manifest, string $from, string $to) } } - private function copyDir(string $source, string $target, array $options) + private function getFilesForDir(string $source, string $target): array { - $overwrite = $options['force'] ?? false; - - if (!is_dir($target)) { - mkdir($target, 0777, true); - } - $iterator = $this->createSourceIterator($source, \RecursiveIteratorIterator::SELF_FIRST); + $files = []; foreach ($iterator as $item) { $targetPath = $this->path->concatenate([$target, $iterator->getSubPathName()]); - if ($item->isDir()) { - if (!is_dir($targetPath)) { - mkdir($targetPath); - $this->write(sprintf(' Created "%s"', $this->path->relativize($targetPath))); - } - } elseif ($overwrite || !file_exists($targetPath)) { - $this->copyFile($item, $targetPath, $options); - } + + $files[(string) $item] = $targetPath; } + + return $files; } + /** + * @param string $source The absolute path to the source file + * @param string $target The relative (to root dir) path to the target + */ public function copyFile(string $source, string $target, array $options) { + $target = $this->options->get('root-dir').'/'.$target; + if (is_dir($source)) { + // directory will be created when a file is copied to it + return; + } + $overwrite = $options['force'] ?? false; if (!$this->options->shouldWriteFile($target, $overwrite)) { return; @@ -103,6 +133,11 @@ public function copyFile(string $source, string $target, array $options) throw new \LogicException(sprintf('File "%s" does not exist!', $source)); } + if (!file_exists(\dirname($target))) { + mkdir(\dirname($target), 0777, true); + $this->write(sprintf(' Created "%s"', $this->path->relativize(\dirname($target)))); + } + file_put_contents($target, $this->options->expandTargetDir(file_get_contents($source))); @chmod($target, fileperms($target) | (fileperms($source) & 0111)); $this->write(sprintf(' Created "%s"', $this->path->relativize($target))); diff --git a/src/Configurator/CopyFromRecipeConfigurator.php b/src/Configurator/CopyFromRecipeConfigurator.php index d413ad54a..b4233111e 100644 --- a/src/Configurator/CopyFromRecipeConfigurator.php +++ b/src/Configurator/CopyFromRecipeConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -33,6 +34,21 @@ public function unconfigure(Recipe $recipe, $config, Lock $lock) $this->removeFiles($config, $this->getRemovableFilesFromRecipeAndLock($recipe, $lock), $this->options->get('root-dir')); } + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + foreach ($recipeUpdate->getOriginalRecipe()->getFiles() as $filename => $data) { + $recipeUpdate->setOriginalFile($filename, $data['contents']); + } + + $files = []; + foreach ($recipeUpdate->getNewRecipe()->getFiles() as $filename => $data) { + $recipeUpdate->setNewFile($filename, $data['contents']); + + $files[] = $this->getLocalFilePath($recipeUpdate->getRootDir(), $filename); + } + $recipeUpdate->getLock()->add($recipeUpdate->getPackageName(), ['files' => $files]); + } + private function getRemovableFilesFromRecipeAndLock(Recipe $recipe, Lock $lock): array { $lockedFiles = array_unique( @@ -96,7 +112,7 @@ private function copyFile(string $to, string $contents, bool $executable, array { $overwrite = $options['force'] ?? false; $basePath = $options['root-dir'] ?? '.'; - $copiedFile = str_replace($basePath.\DIRECTORY_SEPARATOR, '', $to); + $copiedFile = $this->getLocalFilePath($basePath, $to); if (!$this->options->shouldWriteFile($to, $overwrite)) { return $copiedFile; @@ -151,4 +167,9 @@ private function removeFile(string $to) @rmdir(\dirname($to)); } } + + private function getLocalFilePath(string $basePath, $destination): string + { + return str_replace($basePath.\DIRECTORY_SEPARATOR, '', $destination); + } } diff --git a/src/Configurator/DockerComposeConfigurator.php b/src/Configurator/DockerComposeConfigurator.php index 103c73d83..67d5743ed 100644 --- a/src/Configurator/DockerComposeConfigurator.php +++ b/src/Configurator/DockerComposeConfigurator.php @@ -20,6 +20,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * Adds services and volumes to docker-compose.yml file. @@ -45,61 +46,7 @@ public function configure(Recipe $recipe, $config, Lock $lock, array $options = return; } - $rootDir = $this->options->get('root-dir'); - foreach ($this->normalizeConfig($config) as $file => $extra) { - $dockerComposeFile = $this->findDockerComposeFile($rootDir, $file); - if (null === $dockerComposeFile) { - $dockerComposeFile = $rootDir.'/'.$file; - file_put_contents($dockerComposeFile, "version: '3'\n"); - $this->write(sprintf(' Created "%s"', $file)); - } - if ($this->isFileMarked($recipe, $dockerComposeFile)) { - continue; - } - - $this->write(sprintf('Adding Docker Compose definitions to "%s"', $dockerComposeFile)); - - $offset = 2; - $node = null; - $endAt = []; - $lines = []; - foreach (file($dockerComposeFile) as $i => $line) { - $lines[] = $line; - $ltrimedLine = ltrim($line, ' '); - - // Skip blank lines and comments - if (('' !== $ltrimedLine && 0 === strpos($ltrimedLine, '#')) || '' === trim($line)) { - continue; - } - - // Extract Docker Compose keys (usually "services" and "volumes") - if (!preg_match('/^[\'"]?([a-zA-Z0-9]+)[\'"]?:\s*$/', $line, $matches)) { - // Detect indentation to use - $offestLine = \strlen($line) - \strlen($ltrimedLine); - if ($offset > $offestLine && 0 !== $offestLine) { - $offset = $offestLine; - } - continue; - } - - // Keep end in memory (check break line on previous line) - $endAt[$node] = '' !== trim($lines[$i - 1]) ? $i : $i - 1; - $node = $matches[1]; - } - $endAt[$node] = \count($lines) + 1; - - foreach ($extra as $key => $value) { - if (isset($endAt[$key])) { - array_splice($lines, $endAt[$key], 0, $this->markData($recipe, $this->parse(1, $offset, $value))); - continue; - } - - $lines[] = sprintf("\n%s:", $key); - $lines[] = $this->markData($recipe, $this->parse(1, $offset, $value)); - } - - file_put_contents($dockerComposeFile, implode('', $lines)); - } + $this->configureDockerCompose($recipe, $config, $options['force'] ?? false); $this->write('Docker Compose definitions have been modified. Please run "docker-compose up --build" again to apply the changes.'); } @@ -132,6 +79,21 @@ public function unconfigure(Recipe $recipe, $config, Lock $lock) $this->write('Docker Compose definitions have been modified. Please run "docker-compose up" again to apply the changes.'); } + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipeUpdate->getNewRecipe())) { + return; + } + + $recipeUpdate->addOriginalFiles( + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) + ); + + $recipeUpdate->addNewFiles( + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) + ); + } + public static function shouldConfigureDockerRecipe(Composer $composer, IOInterface $io, Recipe $recipe): bool { if (null !== self::$configureDockerRecipes) { @@ -270,4 +232,140 @@ private function parse($level, $indent, $services): string return $line; } + + private function configureDockerCompose(Recipe $recipe, array $config, bool $update): void + { + $rootDir = $this->options->get('root-dir'); + foreach ($this->normalizeConfig($config) as $file => $extra) { + $dockerComposeFile = $this->findDockerComposeFile($rootDir, $file); + if (null === $dockerComposeFile) { + $dockerComposeFile = $rootDir.'/'.$file; + file_put_contents($dockerComposeFile, "version: '3'\n"); + $this->write(sprintf(' Created "%s"', $file)); + } + + if (!$update && $this->isFileMarked($recipe, $dockerComposeFile)) { + continue; + } + + $this->write(sprintf('Adding Docker Compose definitions to "%s"', $dockerComposeFile)); + + $offset = 2; + $node = null; + $endAt = []; + $startAt = []; + $lines = []; + $nodesLines = []; + foreach (file($dockerComposeFile) as $i => $line) { + $lines[] = $line; + $ltrimedLine = ltrim($line, ' '); + if (null !== $node) { + $nodesLines[$node][$i] = $line; + } + + // Skip blank lines and comments + if (('' !== $ltrimedLine && 0 === strpos($ltrimedLine, '#')) || '' === trim($line)) { + continue; + } + + // Extract Docker Compose keys (usually "services" and "volumes") + if (!preg_match('/^[\'"]?([a-zA-Z0-9]+)[\'"]?:\s*$/', $line, $matches)) { + // Detect indentation to use + $offestLine = \strlen($line) - \strlen($ltrimedLine); + if ($offset > $offestLine && 0 !== $offestLine) { + $offset = $offestLine; + } + continue; + } + + // Keep end in memory (check break line on previous line) + $endAt[$node] = '' !== trim($lines[$i - 1]) ? $i : $i - 1; + $node = $matches[1]; + if (!isset($nodesLines[$node])) { + $nodesLines[$node] = []; + } + if (!isset($startAt[$node])) { + // the section contents starts at the next line + $startAt[$node] = $i + 1; + } + } + $endAt[$node] = \count($lines) + 1; + + foreach ($extra as $key => $value) { + if (isset($endAt[$key])) { + $data = $this->markData($recipe, $this->parse(1, $offset, $value)); + $updatedContents = $this->updateDataString(implode('', $nodesLines[$key]), $data); + if (null === $updatedContents) { + // not an update: just add to section + array_splice($lines, $endAt[$key], 0, $data); + + continue; + } + + $originalEndAt = $endAt[$key]; + $length = $endAt[$key] - $startAt[$key]; + array_splice($lines, $startAt[$key], $length, ltrim($updatedContents, "\n")); + + // reset any start/end positions after this to the new positions + foreach ($startAt as $sectionKey => $at) { + if ($at > $originalEndAt) { + $startAt[$sectionKey] = $at - $length - 1; + } + } + foreach ($endAt as $sectionKey => $at) { + if ($at > $originalEndAt) { + $endAt[$sectionKey] = $at - $length; + } + } + + continue; + } + + $lines[] = sprintf("\n%s:", $key); + $lines[] = $this->markData($recipe, $this->parse(1, $offset, $value)); + } + + file_put_contents($dockerComposeFile, implode('', $lines)); + } + } + + private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $config): array + { + if (0 === \count($config)) { + return []; + } + + $files = array_map(function ($file) use ($rootDir) { + return $this->findDockerComposeFile($rootDir, $file); + }, array_keys($config)); + + $originalContents = []; + foreach ($files as $file) { + $originalContents[$file] = file_exists($file) ? file_get_contents($file) : null; + } + + $this->configureDockerCompose( + $recipe, + $config, + true + ); + + $updatedContents = []; + foreach ($files as $file) { + $localPath = ltrim(str_replace($rootDir, '', $file), '/\\'); + $updatedContents[$localPath] = file_exists($file) ? file_get_contents($file) : null; + } + + foreach ($originalContents as $file => $contents) { + if (null === $contents) { + if (file_exists($file)) { + unlink($file); + } + } else { + file_put_contents($file, $contents); + } + } + + return $updatedContents; + } } diff --git a/src/Configurator/DockerfileConfigurator.php b/src/Configurator/DockerfileConfigurator.php index ba8bbbbec..423cf9cff 100644 --- a/src/Configurator/DockerfileConfigurator.php +++ b/src/Configurator/DockerfileConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * Adds commands to a Dockerfile. @@ -27,12 +28,58 @@ public function configure(Recipe $recipe, $config, Lock $lock, array $options = return; } + $this->configureDockerfile($recipe, $config, $options['force'] ?? false); + } + + public function unconfigure(Recipe $recipe, $config, Lock $lock) + { + if (!file_exists($dockerfile = $this->options->get('root-dir').'/Dockerfile')) { + return; + } + + $name = $recipe->getName(); + $contents = preg_replace(sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), "\n", file_get_contents($dockerfile), -1, $count); + if (!$count) { + return; + } + + $this->write('Removing Dockerfile entries'); + file_put_contents($dockerfile, ltrim($contents, "\n")); + } + + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + if (!DockerComposeConfigurator::shouldConfigureDockerRecipe($this->composer, $this->io, $recipeUpdate->getNewRecipe())) { + return; + } + + $recipeUpdate->setOriginalFile( + 'Dockerfile', + $this->getContentsAfterApplyingRecipe($recipeUpdate->getOriginalRecipe(), $originalConfig) + ); + + $recipeUpdate->setNewFile( + 'Dockerfile', + $this->getContentsAfterApplyingRecipe($recipeUpdate->getNewRecipe(), $newConfig) + ); + } + + private function configureDockerfile(Recipe $recipe, array $config, bool $update, bool $writeOutput = true): void + { $dockerfile = $this->options->get('root-dir').'/Dockerfile'; - if (!file_exists($dockerfile) || $this->isFileMarked($recipe, $dockerfile)) { + if (!file_exists($dockerfile) || (!$update && $this->isFileMarked($recipe, $dockerfile))) { return; } - $this->write('Adding Dockerfile entries'); + if ($writeOutput) { + $this->write('Adding Dockerfile entries'); + } + + $data = ltrim($this->markData($recipe, implode("\n", $config)), "\n"); + if ($this->updateData($dockerfile, $data)) { + // done! Existing spot updated + return; + } $lines = []; foreach (file($dockerfile) as $line) { @@ -41,25 +88,38 @@ public function configure(Recipe $recipe, $config, Lock $lock, array $options = continue; } - $lines[] = ltrim($this->markData($recipe, implode("\n", $config)), "\n"); + $lines[] = $data; } file_put_contents($dockerfile, implode('', $lines)); } - public function unconfigure(Recipe $recipe, $config, Lock $lock) + private function getContentsAfterApplyingRecipe(Recipe $recipe, array $config): ?string { - if (!file_exists($dockerfile = $this->options->get('root-dir').'/Dockerfile')) { - return; + if (0 === \count($config)) { + return null; } - $name = $recipe->getName(); - $contents = preg_replace(sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), "\n", file_get_contents($dockerfile), -1, $count); - if (!$count) { - return; + $dockerfile = $this->options->get('root-dir').'/Dockerfile'; + $originalContents = file_exists($dockerfile) ? file_get_contents($dockerfile) : null; + + $this->configureDockerfile( + $recipe, + $config, + true, + false + ); + + $updatedContents = file_exists($dockerfile) ? file_get_contents($dockerfile) : null; + + if (null === $originalContents) { + if (file_exists($dockerfile)) { + unlink($dockerfile); + } + } else { + file_put_contents($dockerfile, $originalContents); } - $this->write('Removing Dockerfile entries'); - file_put_contents($dockerfile, ltrim($contents, "\n")); + return $updatedContents; } } diff --git a/src/Configurator/EnvConfigurator.php b/src/Configurator/EnvConfigurator.php index 2ba74fc99..32f8abcd0 100644 --- a/src/Configurator/EnvConfigurator.php +++ b/src/Configurator/EnvConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -35,6 +36,17 @@ public function unconfigure(Recipe $recipe, $vars, Lock $lock) $this->unconfigurePhpUnit($recipe, $vars); } + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + $recipeUpdate->addOriginalFiles( + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) + ); + + $recipeUpdate->addNewFiles( + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) + ); + } + private function configureEnvDist(Recipe $recipe, $vars, bool $update) { foreach (['.env.dist', '.env'] as $file) { @@ -49,7 +61,8 @@ private function configureEnvDist(Recipe $recipe, $vars, bool $update) $data = ''; foreach ($vars as $key => $value) { - $value = $this->evaluateValue($value); + $existingValue = $update ? $this->findExistingValue($key, $env, $recipe) : null; + $value = $this->evaluateValue($value, $existingValue); if ('#' === $key[0] && is_numeric(substr($key, 1))) { if ('' === $value) { $data .= "#\n"; @@ -154,12 +167,26 @@ private function unconfigurePhpUnit(Recipe $recipe, $vars) } } - private function evaluateValue($value) + /** + * Evaluates expressions like %generate(secret)%. + * + * If $originalValue is passed, and the value contains an expression. + * the $originalValue is used. + */ + private function evaluateValue($value, string $originalValue = null) { if ('%generate(secret)%' === $value) { + if (null !== $originalValue) { + return $originalValue; + } + return $this->generateRandomBytes(); } if (preg_match('~^%generate\(secret,\s*([0-9]+)\)%$~', $value, $matches)) { + if (null !== $originalValue) { + return $originalValue; + } + return $this->generateRandomBytes($matches[1]); } @@ -170,4 +197,76 @@ private function generateRandomBytes($length = 16) { return bin2hex(random_bytes($length)); } + + private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $vars): array + { + $files = ['.env', '.env.dist', 'phpunit.xml.dist', 'phpunit.xml']; + + if (0 === \count($vars)) { + return array_fill_keys($files, null); + } + + $originalContents = []; + foreach ($files as $file) { + $originalContents[$file] = file_exists($rootDir.'/'.$file) ? file_get_contents($rootDir.'/'.$file) : null; + } + + $this->configureEnvDist( + $recipe, + $vars, + true + ); + + if (!file_exists($rootDir.'/.env.test')) { + $this->configurePhpUnit( + $recipe, + $vars, + true + ); + } + + $updatedContents = []; + foreach ($files as $file) { + $updatedContents[$file] = file_exists($rootDir.'/'.$file) ? file_get_contents($rootDir.'/'.$file) : null; + } + + foreach ($originalContents as $file => $contents) { + if (null === $contents) { + if (file_exists($rootDir.'/'.$file)) { + unlink($rootDir.'/'.$file); + } + } else { + file_put_contents($rootDir.'/'.$file, $contents); + } + } + + return $updatedContents; + } + + /** + * Attempts to find the existing value of an environment variable. + */ + private function findExistingValue(string $var, string $filename, Recipe $recipe): ?string + { + if (!file_exists($filename)) { + return null; + } + + $contents = file_get_contents($filename); + $section = $this->extractSection($recipe, $contents); + if (!$section) { + return null; + } + + $lines = explode("\n", $section); + foreach ($lines as $line) { + if (0 !== strpos($line, sprintf('%s=', $var))) { + continue; + } + + return trim(substr($line, \strlen($var) + 1)); + } + + return null; + } } diff --git a/src/Configurator/GitignoreConfigurator.php b/src/Configurator/GitignoreConfigurator.php index 530c5ec48..9d33d6c15 100644 --- a/src/Configurator/GitignoreConfigurator.php +++ b/src/Configurator/GitignoreConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -23,8 +24,42 @@ public function configure(Recipe $recipe, $vars, Lock $lock, array $options = [] { $this->write('Adding entries to .gitignore'); + $this->configureGitignore($recipe, $vars, $options['force'] ?? false); + } + + public function unconfigure(Recipe $recipe, $vars, Lock $lock) + { + $file = $this->options->get('root-dir').'/.gitignore'; + if (!file_exists($file)) { + return; + } + + $contents = preg_replace(sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($file), -1, $count); + if (!$count) { + return; + } + + $this->write('Removing entries in .gitignore'); + file_put_contents($file, ltrim($contents, "\r\n")); + } + + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + $recipeUpdate->setOriginalFile( + '.gitignore', + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) + ); + + $recipeUpdate->setNewFile( + '.gitignore', + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) + ); + } + + private function configureGitignore(Recipe $recipe, array $vars, bool $update) + { $gitignore = $this->options->get('root-dir').'/.gitignore'; - if (empty($options['force']) && $this->isFileMarked($recipe, $gitignore)) { + if (!$update && $this->isFileMarked($recipe, $gitignore)) { return; } @@ -40,19 +75,31 @@ public function configure(Recipe $recipe, $vars, Lock $lock, array $options = [] } } - public function unconfigure(Recipe $recipe, $vars, Lock $lock) + private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, $vars): ?string { - $file = $this->options->get('root-dir').'/.gitignore'; - if (!file_exists($file)) { - return; + if (0 === \count($vars)) { + return null; } - $contents = preg_replace(sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($file), -1, $count); - if (!$count) { - return; + $file = $rootDir.'/.gitignore'; + $originalContents = file_exists($file) ? file_get_contents($file) : null; + + $this->configureGitignore( + $recipe, + $vars, + true + ); + + $updatedContents = file_exists($file) ? file_get_contents($file) : null; + + if (null === $originalContents) { + if (file_exists($file)) { + unlink($file); + } + } else { + file_put_contents($file, $originalContents); } - $this->write('Removing entries in .gitignore'); - file_put_contents($file, ltrim($contents, "\r\n")); + return $updatedContents; } } diff --git a/src/Configurator/MakefileConfigurator.php b/src/Configurator/MakefileConfigurator.php index c20d2479a..5ab400c5b 100644 --- a/src/Configurator/MakefileConfigurator.php +++ b/src/Configurator/MakefileConfigurator.php @@ -13,6 +13,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Fabien Potencier @@ -23,8 +24,45 @@ public function configure(Recipe $recipe, $definitions, Lock $lock, array $optio { $this->write('Adding Makefile entries'); + $this->configureMakefile($recipe, $definitions, $options['force'] ?? false); + } + + public function unconfigure(Recipe $recipe, $vars, Lock $lock) + { + if (!file_exists($makefile = $this->options->get('root-dir').'/Makefile')) { + return; + } + + $contents = preg_replace(sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($makefile), -1, $count); + if (!$count) { + return; + } + + $this->write(sprintf('Removing Makefile entries from %s', $makefile)); + if (!trim($contents)) { + @unlink($makefile); + } else { + file_put_contents($makefile, ltrim($contents, "\r\n")); + } + } + + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + $recipeUpdate->setOriginalFile( + 'Makefile', + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) + ); + + $recipeUpdate->setNewFile( + 'Makefile', + $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) + ); + } + + private function configureMakefile(Recipe $recipe, array $definitions, bool $update) + { $makefile = $this->options->get('root-dir').'/Makefile'; - if (empty($options['force']) && $this->isFileMarked($recipe, $makefile)) { + if (!$update && $this->isFileMarked($recipe, $makefile)) { return; } @@ -54,22 +92,31 @@ public function configure(Recipe $recipe, $definitions, Lock $lock, array $optio } } - public function unconfigure(Recipe $recipe, $vars, Lock $lock) + private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $definitions): ?string { - if (!file_exists($makefile = $this->options->get('root-dir').'/Makefile')) { - return; + if (0 === \count($definitions)) { + return null; } - $contents = preg_replace(sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($makefile), -1, $count); - if (!$count) { - return; - } + $file = $rootDir.'/Makefile'; + $originalContents = file_exists($file) ? file_get_contents($file) : null; - $this->write(sprintf('Removing Makefile entries from %s', $makefile)); - if (!trim($contents)) { - @unlink($makefile); + $this->configureMakefile( + $recipe, + $definitions, + true + ); + + $updatedContents = file_exists($file) ? file_get_contents($file) : null; + + if (null === $originalContents) { + if (file_exists($file)) { + unlink($file); + } } else { - file_put_contents($makefile, ltrim($contents, "\r\n")); + file_put_contents($file, $originalContents); } + + return $updatedContents; } } diff --git a/src/Downloader.php b/src/Downloader.php index 919d68bb2..6943778a6 100644 --- a/src/Downloader.php +++ b/src/Downloader.php @@ -139,6 +139,7 @@ public function getRecipes(array $operations): array $data = []; $urls = []; $chunk = ''; + $recipeRef = null; foreach ($operations as $operation) { $o = 'i'; if ($operation instanceof UpdateOperation) { @@ -149,9 +150,16 @@ public function getRecipes(array $operations): array if ($operation instanceof UninstallOperation) { $o = 'r'; } + + if ($operation instanceof InformationOperation) { + $recipeRef = $operation->getRecipeRef(); + } } $version = $package->getPrettyVersion(); + if ($operation instanceof InformationOperation && $operation->getVersion()) { + $version = $operation->getVersion(); + } if (0 === strpos($version, 'dev-') && isset($package->getExtra()['branch-alias'])) { $branchAliases = $package->getExtra()['branch-alias']; if ( @@ -179,6 +187,16 @@ public function getRecipes(array $operations): array if (version_compare($version, $v, '>=')) { $data['locks'][$package->getName()]['version'] = $version; $data['locks'][$package->getName()]['recipe']['version'] = $v; + + if (null !== $recipeRef && isset($this->endpoints[$endpoint]['_links']['archived_recipes_template'])) { + $urls[] = strtr($this->endpoints[$endpoint]['_links']['archived_recipes_template'], [ + '{package_dotted}' => str_replace('/', '.', $package->getName()), + '{ref}' => $recipeRef, + ]); + + break; + } + $urls[] = strtr($this->endpoints[$endpoint]['_links']['recipe_template'], [ '{package_dotted}' => str_replace('/', '.', $package->getName()), '{package}' => $package->getName(), @@ -418,6 +436,9 @@ private static function generateCacheKey(string $url): string $url = preg_replace('{^https://api.github.com/repos/([^/]++/[^/]++)/contents/}', '$1/', $url); $url = preg_replace('{^https://raw.githubusercontent.com/([^/]++/[^/]++)/}', '$1/', $url); - return preg_replace('{[^a-z0-9.]}i', '-', $url); + $key = preg_replace('{[^a-z0-9.]}i', '-', $url); + + // eCryptfs can have problems with filenames longer than around 143 chars + return \strlen($key) > 140 ? md5($url) : $key; } } diff --git a/src/Flex.php b/src/Flex.php index 0d6913916..fbe82aa97 100644 --- a/src/Flex.php +++ b/src/Flex.php @@ -276,6 +276,7 @@ class_exists(__NAMESPACE__.str_replace('/', '\\', substr($file, \strlen(__DIR__) $app->add(new Command\UnpackCommand($resolver)); $app->add(new Command\RecipesCommand($this, $this->lock, $rfs)); $app->add(new Command\InstallRecipesCommand($this, $this->options->get('root-dir'))); + $app->add(new Command\UpdateRecipesCommand($this, $this->downloader, $rfs, $this->configurator, $this->options->get('root-dir'))); if (class_exists(Command\GenerateIdCommand::class)) { $app->add(new Command\GenerateIdCommand(null)); } @@ -411,8 +412,7 @@ public function install(Event $event) if (!$recipes) { if (ScriptEvents::POST_UPDATE_CMD === $event->getName()) { - $this->synchronizePackageJson($rootDir); - $this->lock->write(); + $this->finish($rootDir); } if ($this->downloader->isEnabled()) { @@ -508,10 +508,15 @@ function ($value) { ); } + $this->finish($rootDir, $originalComposerJsonHash); + } + + public function finish(string $rootDir, string $originalComposerJsonHash = null): void + { $this->synchronizePackageJson($rootDir); $this->lock->write(); - if ($this->getComposerJsonHash() !== $originalComposerJsonHash) { + if ($originalComposerJsonHash && $this->getComposerJsonHash() !== $originalComposerJsonHash) { $this->updateComposerLock(); } } @@ -790,6 +795,20 @@ public function truncatePackages(PrePoolCreateEvent $event) $event->setPackages($this->filter->removeLegacyPackages($event->getPackages(), $rootPackage, $lockedPackages)); } + public function getComposerJsonHash(): string + { + return md5_file(Factory::getComposerFile()); + } + + public function getLock(): Lock + { + if (null === $this->lock) { + throw new \Exception('Cannot access lock before calling activate().'); + } + + return $this->lock; + } + private function initOptions(): Options { $extra = $this->composer->getPackage()->getExtra(); @@ -970,9 +989,4 @@ public static function getSubscribedEvents(): array return $events; } - - private function getComposerJsonHash(): string - { - return md5_file(Factory::getComposerFile()); - } } diff --git a/src/GithubApi.php b/src/GithubApi.php new file mode 100644 index 000000000..60e90181f --- /dev/null +++ b/src/GithubApi.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex; + +use Composer\Util\HttpDownloader; +use Composer\Util\RemoteFilesystem; + +class GithubApi +{ + /** @var HttpDownloader|RemoteFilesystem */ + private $downloader; + + public function __construct($downloader) + { + $this->downloader = $downloader; + } + + /** + * Attempts to find data about when the recipe was installed. + * + * Returns an array containing: + * commit: The git sha of the last commit of the recipe + * date: The date of the commit + * new_commits: An array of commit sha's in this recipe's directory+version since the commit + * The key is the sha & the value is the date + */ + public function findRecipeCommitDataFromTreeRef(string $package, string $repo, string $branch, string $version, string $lockRef): ?array + { + $repositoryName = $this->getRepositoryName($repo); + if (!$repositoryName) { + return null; + } + + $recipePath = sprintf('%s/%s', $package, $version); + $commitsData = $this->requestGitHubApi(sprintf( + 'https://api.github.com/repos/%s/commits?path=%s&sha=%s', + $repositoryName, + $recipePath, + $branch + )); + + $commitShas = []; + foreach ($commitsData as $commitData) { + $commitShas[$commitData['sha']] = $commitData['commit']['committer']['date']; + // go back the commits one-by-one + $treeUrl = $commitData['commit']['tree']['url'].'?recursive=true'; + + // fetch the full tree, then look for the tree for the package path + $treeData = $this->requestGitHubApi($treeUrl); + foreach ($treeData['tree'] as $treeItem) { + if ($treeItem['path'] !== $recipePath) { + continue; + } + + if ($treeItem['sha'] === $lockRef) { + // remove *this* commit from the new commits list + array_pop($commitShas); + + return [ + // shorten for brevity + 'commit' => substr($commitData['sha'], 0, 7), + 'date' => $commitData['commit']['committer']['date'], + 'new_commits' => $commitShas, + ]; + } + } + } + + return null; + } + + public function getVersionsOfRecipe(string $repo, string $branch, string $recipePath): ?array + { + $repositoryName = $this->getRepositoryName($repo); + if (!$repositoryName) { + return null; + } + + $url = sprintf( + 'https://api.github.com/repos/%s/contents/%s?ref=%s', + $repositoryName, + $recipePath, + $branch + ); + $contents = $this->requestGitHubApi($url); + $versions = []; + foreach ($contents as $fileData) { + if ('dir' !== $fileData['type']) { + continue; + } + + $versions[] = $fileData['name']; + } + + return $versions; + } + + public function getCommitDataForPath(string $repo, string $path, string $branch): array + { + $repositoryName = $this->getRepositoryName($repo); + if (!$repositoryName) { + return []; + } + + $commitsData = $this->requestGitHubApi(sprintf( + 'https://api.github.com/repos/%s/commits?path=%s&sha=%s', + $repositoryName, + $path, + $branch + )); + + $data = []; + foreach ($commitsData as $commitData) { + $data[$commitData['sha']] = $commitData['commit']['committer']['date']; + } + + return $data; + } + + public function getPullRequestForCommit(string $commit, string $repo): ?array + { + $data = $this->requestGitHubApi('https://api.github.com/search/issues?q='.$commit); + + if (0 === \count($data['items'])) { + return null; + } + + $repositoryName = $this->getRepositoryName($repo); + if (!$repositoryName) { + return null; + } + + $bestItem = null; + foreach ($data['items'] as $item) { + // make sure the PR referenced isn't from a different repository + if (false === strpos($item['html_url'], sprintf('%s/pull', $repositoryName))) { + continue; + } + + if (null === $bestItem) { + $bestItem = $item; + + continue; + } + + // find the first PR to reference - avoids rare cases where an invalid + // PR that references *many* commits is first + // e.g. https://api.github.com/search/issues?q=a1a70353f64f405cfbacfc4ce860af623442d6e5 + if ($item['number'] < $bestItem['number']) { + $bestItem = $item; + } + } + + if (!$bestItem) { + return null; + } + + return [ + 'number' => $bestItem['number'], + 'url' => $bestItem['html_url'], + 'title' => $bestItem['title'], + ]; + } + + private function requestGitHubApi(string $path) + { + if ($this->downloader instanceof HttpDownloader) { + $contents = $this->downloader->get($path)->getBody(); + } else { + $contents = $this->downloader->getContents('api.github.com', $path, false); + } + + return json_decode($contents, true); + } + + /** + * Converts the "repo" stored in symfony.lock to a repository name. + * + * For example: "github.com/symfony/recipes" => "symfony/recipes" + */ + private function getRepositoryName(string $repo): ?string + { + // only supports public repository placement + if (0 !== strpos($repo, 'github.com')) { + return null; + } + + $parts = explode('/', $repo); + if (3 !== \count($parts)) { + return null; + } + + return implode('/', [$parts[1], $parts[2]]); + } +} diff --git a/src/InformationOperation.php b/src/InformationOperation.php index f44baac11..288dcc6f9 100644 --- a/src/InformationOperation.php +++ b/src/InformationOperation.php @@ -11,12 +11,25 @@ class InformationOperation implements OperationInterface { private $package; + private $recipeRef = null; + private $version = null; public function __construct(PackageInterface $package) { $this->package = $package; } + /** + * Call to get information about a specific version of a recipe. + * + * Both $recipeRef and $version would normally come from the symfony.lock file. + */ + public function setSpecificRecipeVersion(string $recipeRef, string $version) + { + $this->recipeRef = $recipeRef; + $this->version = $version; + } + /** * Returns package instance. * @@ -27,6 +40,16 @@ public function getPackage() return $this->package; } + public function getRecipeRef(): ?string + { + return $this->recipeRef; + } + + public function getVersion(): ?string + { + return $this->version; + } + public function getJobType() { return 'information'; diff --git a/src/PackageJsonSynchronizer.php b/src/PackageJsonSynchronizer.php index 8d1b2c0ee..964da9d3d 100644 --- a/src/PackageJsonSynchronizer.php +++ b/src/PackageJsonSynchronizer.php @@ -16,6 +16,7 @@ use Composer\Semver\Constraint\ConstraintInterface; use Composer\Semver\Intervals; use Composer\Semver\VersionParser; +use Seld\JsonLint\ParsingException; /** * Synchronize package.json files detected in installed PHP packages with @@ -40,7 +41,12 @@ public function shouldSynchronize(): bool public function synchronize(array $phpPackages): bool { // Remove all links and add again only the existing packages - $didAddLink = $this->removePackageJsonLinks((new JsonFile($this->rootDir.'/package.json'))->read()); + try { + $didAddLink = $this->removePackageJsonLinks((new JsonFile($this->rootDir.'/package.json'))->read()); + } catch (ParsingException $e) { + // if package.json is invalid (possible during a recipe upgrade), we can't update the file + return false; + } foreach ($phpPackages as $k => $phpPackage) { if (\is_string($phpPackage)) { @@ -101,7 +107,12 @@ private function addPackageJsonLink(array $phpPackage): bool uksort($devDependencies, 'strnatcmp'); $manipulator->addMainKey('devDependencies', $devDependencies); - file_put_contents($this->rootDir.'/package.json', $manipulator->getContents()); + $newContents = $manipulator->getContents(); + if ($newContents === file_get_contents($this->rootDir.'/package.json')) { + return false; + } + + file_put_contents($this->rootDir.'/package.json', $newContents); return true; } diff --git a/src/Recipe.php b/src/Recipe.php index a93671df2..3c8697245 100644 --- a/src/Recipe.php +++ b/src/Recipe.php @@ -115,4 +115,9 @@ public function getVersion(): string { return $this->lock['recipe']['version'] ?? $this->lock['version']; } + + public function getLock(): array + { + return $this->lock; + } } diff --git a/src/Update/DiffHelper.php b/src/Update/DiffHelper.php new file mode 100644 index 000000000..4e037de40 --- /dev/null +++ b/src/Update/DiffHelper.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Update; + +class DiffHelper +{ + public static function removeFilesFromPatch(string $patch, array $files, array &$removedPatches): string + { + foreach ($files as $filename) { + $start = strpos($patch, sprintf('diff --git a/%s b/%s', $filename, $filename)); + if (false === $start) { + throw new \LogicException(sprintf('Could not find file "%s" in the patch.', $filename)); + } + + $end = strpos($patch, 'diff --git a/', $start + 1); + $contentBefore = substr($patch, 0, $start); + if (false === $end) { + // last patch in the file + $removedPatches[$filename] = rtrim(substr($patch, $start), "\n"); + $patch = rtrim($contentBefore, "\n"); + + continue; + } + + $removedPatches[$filename] = rtrim(substr($patch, $start, $end - $start), "\n"); + $patch = $contentBefore.substr($patch, $end); + } + + return $patch; + } +} diff --git a/src/Update/RecipePatch.php b/src/Update/RecipePatch.php new file mode 100644 index 000000000..ff5f55b8e --- /dev/null +++ b/src/Update/RecipePatch.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Update; + +class RecipePatch +{ + private $patch; + private $blobs; + private $removedPatches; + + public function __construct(string $patch, array $blobs, array $removedPatches = []) + { + $this->patch = $patch; + $this->blobs = $blobs; + $this->removedPatches = $removedPatches; + } + + public function getPatch(): string + { + return $this->patch; + } + + public function getBlobs(): array + { + return $this->blobs; + } + + /** + * Patches for modified files that were removed because the file + * has been deleted in the user's project. + */ + public function getRemovedPatches(): array + { + return $this->removedPatches; + } +} diff --git a/src/Update/RecipePatcher.php b/src/Update/RecipePatcher.php new file mode 100644 index 000000000..288cf864c --- /dev/null +++ b/src/Update/RecipePatcher.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Update; + +use Composer\IO\IOInterface; +use Composer\Util\ProcessExecutor; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; + +class RecipePatcher +{ + private $rootDir; + private $filesystem; + private $io; + private $processExecutor; + + public function __construct(string $rootDir, IOInterface $io) + { + $this->rootDir = $rootDir; + $this->filesystem = new Filesystem(); + $this->io = $io; + $this->processExecutor = new ProcessExecutor($io); + } + + /** + * Applies the patch. If it fails unexpectedly, an exception will be thrown. + * + * @return bool returns true if fully successful, false if conflicts were encountered + */ + public function applyPatch(RecipePatch $patch): bool + { + if (!$patch->getPatch()) { + // nothing to do! + return true; + } + + $addedBlobs = $this->addMissingBlobs($patch->getBlobs()); + + $patchPath = $this->rootDir.'/_flex_recipe_update.patch'; + file_put_contents($patchPath, $patch->getPatch()); + + try { + $this->execute('git update-index --refresh', $this->rootDir); + + $output = ''; + $statusCode = $this->processExecutor->execute('git apply "_flex_recipe_update.patch" -3', $output, $this->rootDir); + + if (0 === $statusCode) { + // successful with no conflicts + return true; + } + + if (false !== strpos($this->processExecutor->getErrorOutput(), 'with conflicts')) { + // successful with conflicts + return false; + } + + throw new \LogicException('Error applying the patch: '.$this->processExecutor->getErrorOutput()); + } finally { + unlink($patchPath); + // clean up any temporary blobs + foreach ($addedBlobs as $filename) { + unlink($filename); + } + } + } + + public function generatePatch(array $originalFiles, array $newFiles): RecipePatch + { + // null implies "file does not exist" + $originalFiles = array_filter($originalFiles, function ($file) { + return null !== $file; + }); + $newFiles = array_filter($newFiles, function ($file) { + return null !== $file; + }); + + // find removed files and add them so they will be deleted + foreach ($originalFiles as $file => $contents) { + if (!isset($newFiles[$file])) { + $newFiles[$file] = null; + } + } + + // If a file is being modified, but does not exist in the current project, + // it cannot be patched. We generate the diff for these, but then remove + // it from the patch (and optionally report this diff to the user). + $modifiedFiles = array_intersect_key(array_keys($originalFiles), array_keys($newFiles)); + $deletedModifiedFiles = []; + foreach ($modifiedFiles as $modifiedFile) { + if (!file_exists($this->rootDir.'/'.$modifiedFile) && $originalFiles[$modifiedFile] !== $newFiles[$modifiedFile]) { + $deletedModifiedFiles[] = $modifiedFile; + } + } + + $tmpPath = sys_get_temp_dir().'/_flex_recipe_update'.uniqid(mt_rand(), true); + $this->filesystem->mkdir($tmpPath); + + try { + $this->execute('git init', $tmpPath); + $this->execute('git config commit.gpgsign false', $tmpPath); + $this->execute('git config user.name "Flex Updater"', $tmpPath); + $this->execute('git config user.email ""', $tmpPath); + + $blobs = []; + if (\count($originalFiles) > 0) { + $this->writeFiles($originalFiles, $tmpPath); + $this->execute('git add -A', $tmpPath); + $this->execute('git commit -m "original files"', $tmpPath); + + $blobs = $this->generateBlobs($originalFiles, $tmpPath); + } + + $this->writeFiles($newFiles, $tmpPath); + $this->execute('git add -A', $tmpPath); + + $patchString = $this->execute('git diff --cached', $tmpPath); + $removedPatches = []; + $patchString = DiffHelper::removeFilesFromPatch($patchString, $deletedModifiedFiles, $removedPatches); + + return new RecipePatch( + $patchString, + $blobs, + $removedPatches + ); + } finally { + try { + $this->filesystem->remove($tmpPath); + } catch (IOException $e) { + // this can sometimes fail due to git file permissions + // if that happens, just leave it: we're in the temp directory anyways + } + } + } + + private function writeFiles(array $files, string $directory): void + { + foreach ($files as $filename => $contents) { + $path = $directory.'/'.$filename; + if (null === $contents) { + if (file_exists($path)) { + unlink($path); + } + + continue; + } + + if (!file_exists(\dirname($path))) { + $this->filesystem->mkdir(\dirname($path)); + } + file_put_contents($path, $contents); + } + } + + private function execute(string $command, string $cwd): string + { + $output = ''; + $statusCode = $this->processExecutor->execute($command, $output, $cwd); + + if (0 !== $statusCode) { + throw new \LogicException(sprintf('Command "%s" failed: "%s". Output: "%s".', $command, $this->processExecutor->getErrorOutput(), $output)); + } + + return $output; + } + + /** + * Adds git blobs for each original file. + * + * For patching to work, each original file & contents needs to be + * available to git as a blob. This is because the patch contains + * the ref to the original blob, and git uses that to find the + * original file (which is needed for the 3-way merge). + */ + private function addMissingBlobs(array $blobs): array + { + $addedBlobs = []; + foreach ($blobs as $hash => $contents) { + $blobPath = $this->rootDir.'/'.$this->getBlobPath($hash); + if (file_exists($blobPath)) { + continue; + } + + $addedBlobs[] = $blobPath; + if (!file_exists(\dirname($blobPath))) { + $this->filesystem->mkdir(\dirname($blobPath)); + } + file_put_contents($blobPath, $contents); + } + + return $addedBlobs; + } + + private function generateBlobs(array $originalFiles, string $originalFilesRoot): array + { + $addedBlobs = []; + foreach ($originalFiles as $filename => $contents) { + // if the file didn't originally exist, no blob needed + if (!file_exists($originalFilesRoot.'/'.$filename)) { + continue; + } + + $hash = trim($this->execute('git hash-object '.ProcessExecutor::escape($filename), $originalFilesRoot)); + $addedBlobs[$hash] = file_get_contents($originalFilesRoot.'/'.$this->getBlobPath($hash)); + } + + return $addedBlobs; + } + + private function getBlobPath(string $hash): string + { + $hashStart = substr($hash, 0, 2); + $hashEnd = substr($hash, 2); + + return '.git/objects/'.$hashStart.'/'.$hashEnd; + } +} diff --git a/src/Update/RecipeUpdate.php b/src/Update/RecipeUpdate.php new file mode 100644 index 000000000..994493831 --- /dev/null +++ b/src/Update/RecipeUpdate.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Update; + +use Symfony\Flex\Lock; +use Symfony\Flex\Recipe; + +class RecipeUpdate +{ + private $originalRecipe; + private $newRecipe; + private $lock; + private $rootDir; + + /** @var string[] */ + private $originalRecipeFiles = []; + /** @var string[] */ + private $newRecipeFiles = []; + private $copyFromPackagePaths = []; + + public function __construct(Recipe $originalRecipe, Recipe $newRecipe, Lock $lock, string $rootDir) + { + $this->originalRecipe = $originalRecipe; + $this->newRecipe = $newRecipe; + $this->lock = $lock; + $this->rootDir = $rootDir; + } + + public function getOriginalRecipe(): Recipe + { + return $this->originalRecipe; + } + + public function getNewRecipe(): Recipe + { + return $this->newRecipe; + } + + public function getLock(): Lock + { + return $this->lock; + } + + public function getRootDir(): string + { + return $this->rootDir; + } + + public function getPackageName(): string + { + return $this->originalRecipe->getName(); + } + + public function setOriginalFile(string $filename, ?string $contents): void + { + $this->originalRecipeFiles[$filename] = $contents; + } + + public function setNewFile(string $filename, ?string $contents): void + { + $this->newRecipeFiles[$filename] = $contents; + } + + public function addOriginalFiles(array $files) + { + foreach ($files as $file => $contents) { + if (null === $contents) { + continue; + } + + $this->setOriginalFile($file, $contents); + } + } + + public function addNewFiles(array $files) + { + foreach ($files as $file => $contents) { + if (null === $contents) { + continue; + } + + $this->setNewFile($file, $contents); + } + } + + public function getOriginalFiles(): array + { + return $this->originalRecipeFiles; + } + + public function getNewFiles(): array + { + return $this->newRecipeFiles; + } + + public function getCopyFromPackagePaths(): array + { + return $this->copyFromPackagePaths; + } + + public function addCopyFromPackagePath(string $source, string $target) + { + $this->copyFromPackagePaths[$source] = $target; + } +} diff --git a/tests/Command/UpdateRecipesCommandTest.php b/tests/Command/UpdateRecipesCommandTest.php new file mode 100644 index 000000000..8b940f9c6 --- /dev/null +++ b/tests/Command/UpdateRecipesCommandTest.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Command; + +use Composer\Factory; +use Composer\IO\BufferIO; +use Composer\Plugin\PluginInterface; +use Composer\Util\Platform; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; +use Symfony\Flex\Command\UpdateRecipesCommand; +use Symfony\Flex\Configurator; +use Symfony\Flex\Downloader; +use Symfony\Flex\Flex; +use Symfony\Flex\Options; +use Symfony\Flex\ParallelDownloader; + +class UpdateRecipesCommandTest extends TestCase +{ + private $io; + + protected function setUp(): void + { + if (method_exists(Platform::class, 'putEnv')) { + Platform::putEnv('COMPOSER', FLEX_TEST_DIR.'/composer.json'); + } else { + putenv('COMPOSER='.FLEX_TEST_DIR.'/composer.json'); + } + } + + protected function tearDown(): void + { + if (method_exists(Platform::class, 'clearEnv')) { + Platform::clearEnv('COMPOSER'); + } else { + putenv('COMPOSER'); + } + + $filesystem = new Filesystem(); + $filesystem->remove(FLEX_TEST_DIR); + } + + /** + * Skip 7.1, simply because there isn't a newer recipe version available + * that we can easily use to assert. + * + * @requires PHP >= 7.2 + */ + public function testCommandUpdatesRecipe() + { + @mkdir(FLEX_TEST_DIR); + (new Process(['git', 'init'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'config', 'user.name', 'Unit test'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'config', 'user.email', ''], FLEX_TEST_DIR))->mustRun(); + + @mkdir(FLEX_TEST_DIR.'/bin'); + + // copy in outdated bin/console and symfony.lock set at the recipe it came from + file_put_contents(FLEX_TEST_DIR.'/bin/console', file_get_contents(__DIR__.'/../Fixtures/update_recipes/console')); + file_put_contents(FLEX_TEST_DIR.'/symfony.lock', file_get_contents(__DIR__.'/../Fixtures/update_recipes/symfony.lock')); + file_put_contents(FLEX_TEST_DIR.'/composer.json', file_get_contents(__DIR__.'/../Fixtures/update_recipes/composer.json')); + + (new Process(['git', 'add', '-A'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'commit', '-m', 'setup of original console files'], FLEX_TEST_DIR))->mustRun(); + + (new Process([__DIR__.'/../../vendor/bin/composer', 'install'], FLEX_TEST_DIR))->mustRun(); + + $command = $this->createCommandUpdateRecipes(); + $command->execute(['package' => 'symfony/console']); + + $this->assertSame(0, $command->getStatusCode()); + $this->assertStringContainsString('Recipe updated', $this->io->getOutput()); + // assert bin/console has changed + $this->assertStringNotContainsString('vendor/autoload.php', file_get_contents(FLEX_TEST_DIR.'/bin/console')); + // assert the recipe was updated + $this->assertStringNotContainsString('c6d02bdfba9da13c22157520e32a602dbee8a75c', file_get_contents(FLEX_TEST_DIR.'/symfony.lock')); + } + + private function createCommandUpdateRecipes(): CommandTester + { + $this->io = new BufferIO(); + $composer = (new Factory())->createComposer($this->io, null, false, FLEX_TEST_DIR); + $flex = new Flex(); + $flex->activate($composer, $this->io); + if (version_compare('2.0.0', PluginInterface::PLUGIN_API_VERSION, '<=')) { + $rfs = Factory::createHttpDownloader($this->io, $composer->getConfig()); + } else { + $rfs = Factory::createRemoteFilesystem($this->io, $composer->getConfig()); + $rfs = new ParallelDownloader($this->io, $composer->getConfig(), $rfs->getOptions(), $rfs->isTlsDisabled()); + } + $options = new Options(['root-dir' => FLEX_TEST_DIR]); + $command = new UpdateRecipesCommand( + $flex, + new Downloader($composer, $this->io, $rfs), + $rfs, + new Configurator($composer, $this->io, $options), + FLEX_TEST_DIR + ); + $command->setIO($this->io); + $command->setComposer($composer); + + $application = new Application(); + $application->add($command); + $command = $application->find('recipes:update'); + + return new CommandTester($command); + } +} diff --git a/tests/Configurator/BundlesConfiguratorTest.php b/tests/Configurator/BundlesConfiguratorTest.php index e7f99a39c..b59fd6498 100644 --- a/tests/Configurator/BundlesConfiguratorTest.php +++ b/tests/Configurator/BundlesConfiguratorTest.php @@ -11,11 +11,14 @@ namespace Symfony\Flex\Tests\Configurator; +use Composer\Composer; +use Composer\IO\IOInterface; use PHPUnit\Framework\TestCase; use Symfony\Flex\Configurator\BundlesConfigurator; use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class BundlesConfiguratorTest extends TestCase { @@ -24,8 +27,8 @@ public function testConfigure() $config = FLEX_TEST_DIR.'/config/bundles.php'; $configurator = new BundlesConfigurator( - $this->getMockBuilder('Composer\Composer')->getMock(), - $this->getMockBuilder('Composer\IO\IOInterface')->getMock(), + $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) ); @@ -49,10 +52,9 @@ public function testConfigure() , file_get_contents($config)); } - public function testConfigureWhenBundlesAlreayExists() + public function testConfigureWhenBundlesAlreadyExists() { - $config = FLEX_TEST_DIR.'/config/bundles.php'; - file_put_contents($config, <<saveBundlesFile(<<getMockBuilder('Composer\Composer')->getMock(), - $this->getMockBuilder('Composer\IO\IOInterface')->getMock(), + $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) ); @@ -84,6 +86,110 @@ public function testConfigureWhenBundlesAlreayExists() ]; EOF - , file_get_contents($config)); + , file_get_contents(FLEX_TEST_DIR.'/config/bundles.php')); + } + + public function testUnconfigure() + { + $this->saveBundlesFile(<< ['all' => true], + BarBundle::class => ['prod' => false, 'all' => true], + OtherBundle::class => ['all' => true], +]; +EOF + ); + + $configurator = new BundlesConfigurator( + $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->createMock(Recipe::class); + $lock = $this->createMock(Lock::class); + + $configurator->unconfigure($recipe, [ + 'BarBundle' => ['dev', 'all'], + ], $lock); + $this->assertEquals(<< ['all' => true], + OtherBundle::class => ['all' => true], +]; + +EOF + , file_get_contents(FLEX_TEST_DIR.'/config/bundles.php')); + } + + public function testUpdate() + { + $configurator = new BundlesConfigurator( + $this->createMock(Composer::class), + $this->createMock(IOInterface::class), + new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) + ); + + $recipeUpdate = new RecipeUpdate( + $this->createMock(Recipe::class), + $this->createMock(Recipe::class), + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + $this->saveBundlesFile(<< ['prod' => false, 'all' => true], + FooBundle::class => ['dev' => true, 'test' => true], + BazBundle::class => ['all' => true], +]; +EOF + ); + + $configurator->update( + $recipeUpdate, + ['FooBundle' => ['dev', 'test']], + ['FooBundle' => ['all'], 'NewBundle' => ['all']] + ); + + $this->assertSame(['config/bundles.php' => << ['prod' => false, 'all' => true], + FooBundle::class => ['dev' => true, 'test' => true], + BazBundle::class => ['all' => true], +]; + +EOF + ], $recipeUpdate->getOriginalFiles()); + + $this->assertSame(['config/bundles.php' => << ['prod' => false, 'all' => true], + FooBundle::class => ['all' => true], + BazBundle::class => ['all' => true], + NewBundle::class => ['all' => true], +]; + +EOF + ], $recipeUpdate->getNewFiles()); + } + + private function saveBundlesFile(string $contents) + { + $config = FLEX_TEST_DIR.'/config/bundles.php'; + if (!file_exists(\dirname($config))) { + @mkdir(\dirname($config), 0777, true); + } + file_put_contents($config, $contents); } } diff --git a/tests/Configurator/ComposerScriptsConfiguratorTest.php b/tests/Configurator/ComposerScriptsConfiguratorTest.php new file mode 100644 index 000000000..b97e24b1b --- /dev/null +++ b/tests/Configurator/ComposerScriptsConfiguratorTest.php @@ -0,0 +1,213 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Configurator; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\Util\Platform; +use PHPUnit\Framework\TestCase; +use Symfony\Flex\Configurator\ComposerScriptsConfigurator; +use Symfony\Flex\Lock; +use Symfony\Flex\Options; +use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; + +class ComposerScriptsConfiguratorTest extends TestCase +{ + protected function setUp(): void + { + @mkdir(FLEX_TEST_DIR); + if (method_exists(Platform::class, 'putEnv')) { + Platform::putEnv('COMPOSER', FLEX_TEST_DIR.'/composer.json'); + } else { + putenv('COMPOSER='.FLEX_TEST_DIR.'/composer.json'); + } + } + + protected function tearDown(): void + { + @unlink(FLEX_TEST_DIR.'/composer.json'); + @rmdir(FLEX_TEST_DIR); + if (method_exists(Platform::class, 'clearEnv')) { + Platform::clearEnv('COMPOSER'); + } else { + putenv('COMPOSER'); + } + } + + public function testConfigure() + { + file_put_contents(FLEX_TEST_DIR.'/composer.json', json_encode([ + 'scripts' => [ + 'auto-scripts' => [ + 'cache:clear' => 'symfony-cmd', + 'assets:install %PUBLIC_DIR%' => 'symfony-cmd', + ], + 'post-install-cmd' => ['@auto-scripts'], + 'post-update-cmd' => ['@auto-scripts'], + ], + ], \JSON_PRETTY_PRINT)); + + $configurator = new ComposerScriptsConfigurator( + $this->createMock(Composer::class), + $this->createMock(IOInterface::class), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + + $configurator->configure($recipe, [ + 'do:cool-stuff' => 'symfony-cmd', + ], $lock); + $this->assertEquals(<< [ + 'auto-scripts' => [ + 'cache:clear' => 'symfony-cmd', + 'assets:install %PUBLIC_DIR%' => 'symfony-cmd', + ], + 'post-install-cmd' => ['@auto-scripts'], + 'post-update-cmd' => ['@auto-scripts'], + ], + ], \JSON_PRETTY_PRINT)); + + $configurator = new ComposerScriptsConfigurator( + $this->createMock(Composer::class), + $this->createMock(IOInterface::class), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->createMock(Recipe::class); + $lock = $this->createMock(Lock::class); + + $configurator->unconfigure($recipe, [ + 'do:cool-stuff' => 'symfony-cmd', + 'cache:clear' => 'symfony-cmd', + ], $lock); + $this->assertEquals(<<createMock(Composer::class), + $this->createMock(IOInterface::class), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + + $recipeUpdate = new RecipeUpdate( + $this->createMock(Recipe::class), + $this->createMock(Recipe::class), + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + file_put_contents(FLEX_TEST_DIR.'/composer.json', json_encode([ + 'scripts' => [ + 'auto-scripts' => [ + 'cache:clear' => 'symfony-cmd', + 'assets:install %PUBLIC_DIR%' => 'symfony-cmd', + ], + 'post-install-cmd' => ['@auto-scripts'], + 'post-update-cmd' => ['@auto-scripts'], + ], + ], \JSON_PRETTY_PRINT)); + + $configurator->update( + $recipeUpdate, + ['cache:clear' => 'symfony-cmd'], + ['cache:clear' => 'other-cmd', 'do:cool-stuff' => 'symfony-cmd'] + ); + + $expectedComposerJsonOriginal = <<assertSame(['composer.json' => $expectedComposerJsonOriginal], $recipeUpdate->getOriginalFiles()); + + $expectedComposerJsonNew = <<assertSame(['composer.json' => $expectedComposerJsonNew], $recipeUpdate->getNewFiles()); + } +} diff --git a/tests/Configurator/ContainerConfiguratorTest.php b/tests/Configurator/ContainerConfiguratorTest.php index 57c18d0d8..d678d9b19 100644 --- a/tests/Configurator/ContainerConfiguratorTest.php +++ b/tests/Configurator/ContainerConfiguratorTest.php @@ -18,14 +18,21 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class ContainerConfiguratorTest extends TestCase { + protected function setUp(): void + { + @mkdir(FLEX_TEST_DIR); + } + public function testConfigure() { $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); $config = FLEX_TEST_DIR.'/config/services.yaml'; + @mkdir(\dirname($config)); file_put_contents( $config, <<createMock(Composer::class), + $this->createMock(IOInterface::class), + new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) + ); + + $recipeUpdate = new RecipeUpdate( + $this->createMock(Recipe::class), + $this->createMock(Recipe::class), + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR.'/config'); + file_put_contents( + FLEX_TEST_DIR.'/config/services.yaml', + <<update( + $recipeUpdate, + ['locale' => 'en', 'foobar' => 'baz'], + ['locale' => 'fr', 'foobar' => 'baz', 'new_one' => 'hallo'] + ); + + $this->assertSame(['config/services.yaml' => <<getOriginalFiles()); + + $this->assertSame(['config/services.yaml' => <<getNewFiles()); + } } diff --git a/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php b/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php index aa7e0e2a9..43f5f5dea 100644 --- a/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php +++ b/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php @@ -20,6 +20,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class CopyDirectoryFromPackageConfiguratorTest extends TestCase { @@ -99,6 +100,48 @@ public function providerTestConfigureDirectoryWithExistingFiles(): array ]; } + public function testUpdate() + { + $configurator = $this->createConfigurator(); + + $recipeUpdate = new RecipeUpdate( + $this->createMock(Recipe::class), + $this->recipe, + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR.'/package/files1', 0777, true); + @mkdir(FLEX_TEST_DIR.'/package/files2', 0777, true); + @mkdir(FLEX_TEST_DIR.'/package/files3', 0777, true); + + touch(FLEX_TEST_DIR.'/package/files1/1a.txt'); + touch(FLEX_TEST_DIR.'/package/files1/1b.txt'); + touch(FLEX_TEST_DIR.'/package/files2/2a.txt'); + touch(FLEX_TEST_DIR.'/package/files2/2b.txt'); + touch(FLEX_TEST_DIR.'/package/files3/3a.txt'); + touch(FLEX_TEST_DIR.'/package/files3/3b.txt'); + + $configurator->update( + $recipeUpdate, + ['package/files1/' => 'target/files1/', 'package/files2/' => 'target/files2/'], + ['package/files1/' => 'target/files1/', 'package/files3/' => 'target/files3/'] + ); + + // original files always show as empty: we don't know what they are + // even for "package/files2", which was removed, for safety, we don't delete it + $this->assertSame([], $recipeUpdate->getOriginalFiles()); + + // only NEW copy paths are installed + $newFiles = array_keys($recipeUpdate->getNewFiles()); + asort($newFiles); + $this->assertSame( + ['target/files3/3a.txt', 'target/files3/3b.txt'], + array_values($newFiles) + ); + $this->assertSame([FLEX_TEST_DIR.'/package/files1/' => 'target/files1/'], $recipeUpdate->getCopyFromPackagePaths()); + } + protected function setUp(): void { parent::setUp(); diff --git a/tests/Configurator/CopyFromPackageConfiguratorTest.php b/tests/Configurator/CopyFromPackageConfiguratorTest.php index 8933121d1..56e8a8d6b 100644 --- a/tests/Configurator/CopyFromPackageConfiguratorTest.php +++ b/tests/Configurator/CopyFromPackageConfiguratorTest.php @@ -73,8 +73,7 @@ public function testConfigureAndOverwriteFiles() public function testSourceFileNotExist() { - $this->io->expects($this->at(0))->method('writeError')->with([' Copying files from package']); - $this->io->expects($this->at(1))->method('writeError')->with([' Created "./public/"']); + $this->io->expects($this->once())->method('writeError')->with([' Copying files from package']); $this->expectException(LogicException::class); $this->expectExceptionMessage(sprintf('File "%s" does not exist!', $this->sourceFile)); $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); diff --git a/tests/Configurator/CopyFromRecipeConfiguratorTest.php b/tests/Configurator/CopyFromRecipeConfiguratorTest.php index 00518b33c..b0e943612 100644 --- a/tests/Configurator/CopyFromRecipeConfiguratorTest.php +++ b/tests/Configurator/CopyFromRecipeConfiguratorTest.php @@ -18,6 +18,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class CopyFromRecipeConfiguratorTest extends TestCase { @@ -33,7 +34,7 @@ class CopyFromRecipeConfiguratorTest extends TestCase public function testNoFilesCopied() { if (!file_exists($this->targetDirectory)) { - mkdir($this->targetDirectory); + @mkdir($this->targetDirectory, 0777, true); } file_put_contents($this->targetFile, ''); $this->io->expects($this->exactly(1))->method('writeError')->with([' Copying files from recipe']); @@ -134,6 +135,61 @@ public function testNoFilesRemoved() $this->createConfigurator()->unconfigure($this->recipe, [$this->sourceFileRelativePath => $this->targetFileRelativePath], $lock); } + public function testUpdate() + { + $configurator = $this->createConfigurator(); + + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('add') + ->with('test-package', ['files' => ['config/packages/webpack_encore.yaml', 'config/packages/new.yaml']]); + + $originalRecipeFiles = [ + 'config/packages/webpack_encore.yaml' => '... encore', + 'config/packages/other.yaml' => '... other', + ]; + $newRecipeFiles = [ + 'config/packages/webpack_encore.yaml' => '... encore_updated', + 'config/packages/new.yaml' => '... new', + ]; + + $originalRecipeFileData = []; + foreach ($originalRecipeFiles as $file => $contents) { + $originalRecipeFileData[$file] = ['contents' => $contents, 'executable' => false]; + } + + $newRecipeFileData = []; + foreach ($newRecipeFiles as $file => $contents) { + $newRecipeFileData[$file] = ['contents' => $contents, 'executable' => false]; + } + + $originalRecipe = $this->createMock(Recipe::class); + $originalRecipe->method('getName') + ->willReturn('test-package'); + $originalRecipe->method('getFiles') + ->willReturn($originalRecipeFileData); + + $newRecipe = $this->createMock(Recipe::class); + $newRecipe->method('getFiles') + ->willReturn($newRecipeFileData); + + $recipeUpdate = new RecipeUpdate( + $originalRecipe, + $newRecipe, + $lock, + FLEX_TEST_DIR + ); + + $configurator->update( + $recipeUpdate, + [], + [] + ); + + $this->assertSame($originalRecipeFiles, $recipeUpdate->getOriginalFiles()); + $this->assertSame($newRecipeFiles, $recipeUpdate->getNewFiles()); + } + protected function setUp(): void { parent::setUp(); diff --git a/tests/Configurator/DockerComposeConfiguratorTest.php b/tests/Configurator/DockerComposeConfiguratorTest.php index a0ea32d23..a2f3b947d 100644 --- a/tests/Configurator/DockerComposeConfiguratorTest.php +++ b/tests/Configurator/DockerComposeConfiguratorTest.php @@ -20,13 +20,14 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; /** * @author Kévin Dunglas */ class DockerComposeConfiguratorTest extends TestCase { - const ORIGINAL_CONTENT = <<<'YAML' + public const ORIGINAL_CONTENT = <<<'YAML' version: '3.4' services: @@ -75,7 +76,7 @@ class DockerComposeConfiguratorTest extends TestCase YAML; - const CONFIG_DB = [ + public const CONFIG_DB = [ 'services' => [ 'db:', ' image: mariadb:10.3', @@ -93,7 +94,7 @@ class DockerComposeConfiguratorTest extends TestCase 'volumes' => ['db-data: {}'], ]; - const CONFIG_DB_MULTIPLE_FILES = [ + public const CONFIG_DB_MULTIPLE_FILES = [ 'docker-compose.yml' => self::CONFIG_DB, 'docker-compose.override.yml' => self::CONFIG_DB, ]; @@ -637,4 +638,176 @@ public function testConfigureWithoutExistingDockerComposeFiles() $this->configurator->unconfigure($this->recipeDb, self::CONFIG_DB, $this->lock); $this->assertEquals(trim($defaultContent), file_get_contents($dockerComposeFile)); } + + public function testUpdate() + { + $recipe = $this->createMock(Recipe::class); + $recipe->method('getName') + ->willReturn('doctrine/doctrine-bundle'); + + $recipeUpdate = new RecipeUpdate( + $recipe, + $recipe, + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR); + file_put_contents( + FLEX_TEST_DIR.'/docker-compose.yml', + << doctrine/doctrine-bundle ### + database: + image: postgres:13-alpine + environment: + POSTGRES_DB: app + POSTGRES_PASSWORD: CUSTOMIZED_PASSWORD + POSTGRES_USER: CUSTOMIZED_USER + volumes: + - db-data:/var/lib/postgresql/data:rw +###< doctrine/doctrine-bundle ### + +###> symfony/mercure-bundle ### + mercure: + image: dunglas/mercure + restart: unless-stopped + # Comment the following line to disable the development mode + command: /usr/bin/caddy run -config /etc/caddy/Caddyfile.dev + volumes: + - mercure_data:/data + - mercure_config:/config +###< symfony/mercure-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + db-data: +###< doctrine/doctrine-bundle ### + +###> symfony/mercure-bundle ### + mercure_data: + mercure_config: +###< symfony/mercure-bundle ### + +EOF + ); + + $this->configurator->update( + $recipeUpdate, + [ + 'docker-compose.yml' => [ + 'services' => [ + 'database:', + ' image: postgres:13-alpine', + ' environment:', + ' POSTGRES_DB: app', + ' POSTGRES_PASSWORD: ChangeMe', + ' POSTGRES_USER: symfony', + ' volumes:', + ' - db-data:/var/lib/postgresql/data:rw', + ], + 'volumes' => [ + 'db-data:', + ], + ], + ], + [ + 'docker-compose.yml' => [ + 'services' => [ + 'database:', + ' image: postgres:13-alpine', + ' environment:', + ' POSTGRES_DB: app', + ' POSTGRES_PASSWORD: ChangeMe', + ' POSTGRES_USER: symfony', + ' volumes:', + ' - MY_DATABASE-data:/var/lib/postgresql/data:rw', + ], + 'volumes' => [ + 'MY_DATABASE:', + ], + ], + ] + ); + + $this->assertSame(['docker-compose.yml' => << doctrine/doctrine-bundle ### + database: + image: postgres:13-alpine + environment: + POSTGRES_DB: app + POSTGRES_PASSWORD: ChangeMe + POSTGRES_USER: symfony + volumes: + - db-data:/var/lib/postgresql/data:rw +###< doctrine/doctrine-bundle ### + +###> symfony/mercure-bundle ### + mercure: + image: dunglas/mercure + restart: unless-stopped + # Comment the following line to disable the development mode + command: /usr/bin/caddy run -config /etc/caddy/Caddyfile.dev + volumes: + - mercure_data:/data + - mercure_config:/config +###< symfony/mercure-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + db-data: +###< doctrine/doctrine-bundle ### + +###> symfony/mercure-bundle ### + mercure_data: + mercure_config: +###< symfony/mercure-bundle ### + +EOF + ], $recipeUpdate->getOriginalFiles()); + + $this->assertSame(['docker-compose.yml' => << doctrine/doctrine-bundle ### + database: + image: postgres:13-alpine + environment: + POSTGRES_DB: app + POSTGRES_PASSWORD: ChangeMe + POSTGRES_USER: symfony + volumes: + - MY_DATABASE-data:/var/lib/postgresql/data:rw +###< doctrine/doctrine-bundle ### + +###> symfony/mercure-bundle ### + mercure: + image: dunglas/mercure + restart: unless-stopped + # Comment the following line to disable the development mode + command: /usr/bin/caddy run -config /etc/caddy/Caddyfile.dev + volumes: + - mercure_data:/data + - mercure_config:/config +###< symfony/mercure-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + MY_DATABASE: +###< doctrine/doctrine-bundle ### + +###> symfony/mercure-bundle ### + mercure_data: + mercure_config: +###< symfony/mercure-bundle ### + +EOF + ], $recipeUpdate->getNewFiles()); + } } diff --git a/tests/Configurator/DockerfileConfiguratorTest.php b/tests/Configurator/DockerfileConfiguratorTest.php index 3451683dd..2b1ae400e 100644 --- a/tests/Configurator/DockerfileConfiguratorTest.php +++ b/tests/Configurator/DockerfileConfiguratorTest.php @@ -18,6 +18,7 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class DockerfileConfiguratorTest extends TestCase { @@ -107,17 +108,7 @@ public function testConfigure() $config = FLEX_TEST_DIR.'/Dockerfile'; file_put_contents($config, $originalContent); - $package = new Package('dummy/dummy', '1.0.0', '1.0.0'); - $package->setExtra(['symfony' => ['docker' => true]]); - - $composer = $this->getMockBuilder(Composer::class)->getMock(); - $composer->method('getPackage')->willReturn($package); - - $configurator = new DockerfileConfigurator( - $composer, - $this->getMockBuilder(IOInterface::class)->getMock(), - new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) - ); + $configurator = $this->createConfigurator(); $configurator->configure($recipe, ['RUN docker-php-ext-install pdo_mysql'], $lock); $this->assertEquals(<<<'EOF' FROM php:7.1-fpm-alpine @@ -193,4 +184,110 @@ public function testConfigure() $configurator->unconfigure($recipe, [], $lock); $this->assertEquals($originalContent, file_get_contents($config)); } + + public function testUpdate() + { + $configurator = $this->createConfigurator(); + $recipe = $this->createMock(Recipe::class); + $recipe->method('getName') + ->willReturn('dummy/dummy'); + + $recipeUpdate = new RecipeUpdate( + $recipe, + $recipe, + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR); + file_put_contents( + FLEX_TEST_DIR.'/Dockerfile', + << recipes ### +###> dummy/dummy ### +RUN docker-php-ext-install pdo_dummyv1 +# my custom line +###< dummy/dummy ### +###> doctrine/doctrine-bundle ### +RUN docker-php-ext-install pdo_mysql +###< doctrine/doctrine-bundle ### +###< recipes ## + +COPY docker/app/install-composer.sh /usr/local/bin/docker-app-install-composer +RUN chmod +x /usr/local/bin/docker-app-install-composer + +EOF + ); + + $configurator->update( + $recipeUpdate, + ['RUN docker-php-ext-install pdo_dummyv1'], + ['RUN docker-php-ext-install pdo_dummyv2'] + ); + + $this->assertSame(['Dockerfile' => << recipes ### +###> dummy/dummy ### +RUN docker-php-ext-install pdo_dummyv1 +###< dummy/dummy ### +###> doctrine/doctrine-bundle ### +RUN docker-php-ext-install pdo_mysql +###< doctrine/doctrine-bundle ### +###< recipes ## + +COPY docker/app/install-composer.sh /usr/local/bin/docker-app-install-composer +RUN chmod +x /usr/local/bin/docker-app-install-composer + +EOF + ], $recipeUpdate->getOriginalFiles()); + + $this->assertSame(['Dockerfile' => << recipes ### +###> dummy/dummy ### +RUN docker-php-ext-install pdo_dummyv2 +###< dummy/dummy ### +###> doctrine/doctrine-bundle ### +RUN docker-php-ext-install pdo_mysql +###< doctrine/doctrine-bundle ### +###< recipes ## + +COPY docker/app/install-composer.sh /usr/local/bin/docker-app-install-composer +RUN chmod +x /usr/local/bin/docker-app-install-composer + +EOF + ], $recipeUpdate->getNewFiles()); + } + + private function createConfigurator(): DockerfileConfigurator + { + $package = new Package('dummy/dummy', '1.0.0', '1.0.0'); + $package->setExtra(['symfony' => ['docker' => true]]); + + $composer = $this->getMockBuilder(Composer::class)->getMock(); + $composer->method('getPackage')->willReturn($package); + + return new DockerfileConfigurator( + $composer, + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) + ); + } } diff --git a/tests/Configurator/EnvConfiguratorTest.php b/tests/Configurator/EnvConfiguratorTest.php index f65ca5b23..4ea4764cb 100644 --- a/tests/Configurator/EnvConfiguratorTest.php +++ b/tests/Configurator/EnvConfiguratorTest.php @@ -18,11 +18,13 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class EnvConfiguratorTest extends TestCase { public function testConfigure() { + @mkdir(FLEX_TEST_DIR); $configurator = new EnvConfigurator( $this->getMockBuilder(Composer::class)->getMock(), $this->getMockBuilder(IOInterface::class)->getMock(), @@ -152,6 +154,7 @@ public function testConfigure() public function testConfigureGeneratedSecret() { + @mkdir(FLEX_TEST_DIR); $configurator = new EnvConfigurator( $this->getMockBuilder(Composer::class)->getMock(), $this->getMockBuilder(IOInterface::class)->getMock(), @@ -199,6 +202,7 @@ public function testConfigureGeneratedSecret() public function testConfigureForce() { + @mkdir(FLEX_TEST_DIR); $configurator = new EnvConfigurator( $this->getMockBuilder(Composer::class)->getMock(), $this->getMockBuilder(IOInterface::class)->getMock(), @@ -338,4 +342,74 @@ public function testConfigureForce() @unlink($phpunit); @unlink($phpunitDist); } + + public function testUpdate() + { + $configurator = new EnvConfigurator( + $this->createMock(Composer::class), + $this->createMock(IOInterface::class), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->createMock(Recipe::class); + $recipe->method('getName') + ->willReturn('symfony/foo-bundle'); + $recipeUpdate = new RecipeUpdate( + $recipe, + $recipe, + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR); + file_put_contents( + FLEX_TEST_DIR.'/.env', + << symfony/foo-bundle ### +APP_ENV="test bar" +APP_SECRET=EXISTING_SECRET_VALUE +APP_DEBUG=0 +###< symfony/foo-bundle ### +###> symfony/baz-bundle ### +OTHER_VAR=1 +###< symfony/baz-bundle ### +EOF + ); + + $configurator->update( + $recipeUpdate, + // %generate(secret)% should not regenerate a new value + ['APP_ENV' => 'original', 'APP_SECRET' => '%generate(secret)%', 'APP_DEBUG' => 0, 'EXTRA_VAR' => 'apple'], + ['APP_ENV' => 'updated', 'APP_SECRET' => '%generate(secret)%', 'APP_DEBUG' => 0, 'NEW_VAR' => 'orange'] + ); + + $this->assertSame(['.env' => << symfony/foo-bundle ### +APP_ENV=original +APP_SECRET=EXISTING_SECRET_VALUE +APP_DEBUG=0 +EXTRA_VAR=apple +###< symfony/foo-bundle ### +###> symfony/baz-bundle ### +OTHER_VAR=1 +###< symfony/baz-bundle ### +EOF + ], $recipeUpdate->getOriginalFiles()); + + $this->assertSame(['.env' => << symfony/foo-bundle ### +APP_ENV=updated +APP_SECRET=EXISTING_SECRET_VALUE +APP_DEBUG=0 +NEW_VAR=orange +###< symfony/foo-bundle ### +###> symfony/baz-bundle ### +OTHER_VAR=1 +###< symfony/baz-bundle ### +EOF + ], $recipeUpdate->getNewFiles()); + } } diff --git a/tests/Configurator/GitignoreConfiguratorTest.php b/tests/Configurator/GitignoreConfiguratorTest.php index dc0e90477..b483eb279 100644 --- a/tests/Configurator/GitignoreConfiguratorTest.php +++ b/tests/Configurator/GitignoreConfiguratorTest.php @@ -18,11 +18,13 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class GitignoreConfiguratorTest extends TestCase { public function testConfigure() { + @mkdir(FLEX_TEST_DIR); $configurator = new GitignoreConfigurator( $this->getMockBuilder(Composer::class)->getMock(), $this->getMockBuilder(IOInterface::class)->getMock(), @@ -83,6 +85,7 @@ public function testConfigure() public function testConfigureForce() { + @mkdir(FLEX_TEST_DIR); $configurator = new GitignoreConfigurator( $this->getMockBuilder(Composer::class)->getMock(), $this->getMockBuilder(IOInterface::class)->getMock(), @@ -134,4 +137,86 @@ public function testConfigureForce() @unlink($gitignore); } + + public function testUpdate() + { + $configurator = new GitignoreConfigurator( + $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['public-dir' => 'public', 'root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->createMock(Recipe::class); + $recipe->method('getName') + ->willReturn('symfony/foo-bundle'); + $recipeUpdate = new RecipeUpdate( + $recipe, + $recipe, + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR); + file_put_contents( + FLEX_TEST_DIR.'/.gitignore', + << symfony/foo-bundle ### +/.env.local +/.env.CUSTOMIZED.php +/.env.*.local +/new_custom_entry +###< symfony/foo-bundle ### + +###> symfony/bar-bundle ### +/.bar_file +###< symfony/bar-bundle ### + +# new content +EOF + ); + + $configurator->update( + $recipeUpdate, + ['/.env.local', '/.env.local.php', '/.env.*.local', '/vendor/'], + ['/.env.LOCAL', '/.env.LOCAL.php', '/.env.*.LOCAL', '/%VAR_DIR%/'] + ); + + $this->assertSame(['.gitignore' => << symfony/foo-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/vendor/ +###< symfony/foo-bundle ### + +###> symfony/bar-bundle ### +/.bar_file +###< symfony/bar-bundle ### + +# new content +EOF + ], $recipeUpdate->getOriginalFiles()); + + $this->assertSame(['.gitignore' => << symfony/foo-bundle ### +/.env.LOCAL +/.env.LOCAL.php +/.env.*.LOCAL +/%VAR_DIR%/ +###< symfony/foo-bundle ### + +###> symfony/bar-bundle ### +/.bar_file +###< symfony/bar-bundle ### + +# new content +EOF + ], $recipeUpdate->getNewFiles()); + } } diff --git a/tests/Configurator/MakefileConfiguratorTest.php b/tests/Configurator/MakefileConfiguratorTest.php index a00c5cb23..66ad1794b 100644 --- a/tests/Configurator/MakefileConfiguratorTest.php +++ b/tests/Configurator/MakefileConfiguratorTest.php @@ -18,9 +18,15 @@ use Symfony\Flex\Lock; use Symfony\Flex\Options; use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; class MakefileConfiguratorTest extends TestCase { + protected function setUp(): void + { + @mkdir(FLEX_TEST_DIR); + } + public function testConfigure() { $configurator = new MakefileConfigurator( @@ -125,4 +131,104 @@ public function testConfigureForce() ]); $this->assertStringEqualsFile($makefile, $contentForce); } + + public function testUpdate() + { + $configurator = new MakefileConfigurator( + $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->createMock(Recipe::class); + $recipe->method('getName') + ->willReturn('symfony/foo-bundle'); + $recipeUpdate = new RecipeUpdate( + $recipe, + $recipe, + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR); + file_put_contents( + FLEX_TEST_DIR.'/Makefile', + << symfony/foo-bundle ### +CONSOLE := $(shell which bin/console) +sf_console_CUSTOM: +ifndef CONSOLE + @printf "Run composer require cli to install the Symfony console." +endif +###< symfony/foo-bundle ### +###> symfony/bar-bundle ### +cache-clear: +ifdef CONSOLE + @$(CONSOLE) cache:clear --no-warmup +else + @rm -rf var/cache/* +endif +.PHONY: cache-clear +###< symfony/bar-bundle ### +EOF + ); + + $configurator->update( + $recipeUpdate, + [ + 'CONSOLE := $(shell which bin/console)', + 'sf_console:', + 'ifndef CONSOLE', + ' @printf "Run composer require cli to install the Symfony console."', + 'endif', + ], + [ + 'CONSOLE := $(shell which bin/console)', + 'sf_console_CHANGED:', + 'ifndef CONSOLE', + ' @printf "Run composer require cli to install the Symfony console."', + 'endif', + ] + ); + + $this->assertSame(['Makefile' => << symfony/foo-bundle ### +CONSOLE := $(shell which bin/console) +sf_console: +ifndef CONSOLE + @printf "Run composer require cli to install the Symfony console." +endif +###< symfony/foo-bundle ### +###> symfony/bar-bundle ### +cache-clear: +ifdef CONSOLE + @$(CONSOLE) cache:clear --no-warmup +else + @rm -rf var/cache/* +endif +.PHONY: cache-clear +###< symfony/bar-bundle ### +EOF + ], $recipeUpdate->getOriginalFiles()); + + $this->assertSame(['Makefile' => << symfony/foo-bundle ### +CONSOLE := $(shell which bin/console) +sf_console_CHANGED: +ifndef CONSOLE + @printf "Run composer require cli to install the Symfony console." +endif +###< symfony/foo-bundle ### +###> symfony/bar-bundle ### +cache-clear: +ifdef CONSOLE + @$(CONSOLE) cache:clear --no-warmup +else + @rm -rf var/cache/* +endif +.PHONY: cache-clear +###< symfony/bar-bundle ### +EOF + ], $recipeUpdate->getNewFiles()); + } } diff --git a/tests/Fixtures/update_recipes/composer.json b/tests/Fixtures/update_recipes/composer.json new file mode 100644 index 000000000..c79cbe473 --- /dev/null +++ b/tests/Fixtures/update_recipes/composer.json @@ -0,0 +1,13 @@ +{ + "type": "project", + "license": "proprietary", + "require": { + "php": ">=7.1", + "symfony/console": "5.4.*" + }, + "config": { + "allow-plugins": { + "symfony/flex": true + } + } +} diff --git a/tests/Fixtures/update_recipes/console b/tests/Fixtures/update_recipes/console new file mode 100644 index 000000000..8fe9d4948 --- /dev/null +++ b/tests/Fixtures/update_recipes/console @@ -0,0 +1,43 @@ +#!/usr/bin/env php +getParameterOption(['--env', '-e'], null, true)) { + putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); +} + +if ($input->hasParameterOption('--no-debug', true)) { + putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); +} + +(new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); + +if ($_SERVER['APP_DEBUG']) { + umask(0000); + + if (class_exists(Debug::class)) { + Debug::enable(); + } +} + +$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); +$application = new Application($kernel); +$application->run($input); diff --git a/tests/Fixtures/update_recipes/symfony.lock b/tests/Fixtures/update_recipes/symfony.lock new file mode 100644 index 000000000..e12664599 --- /dev/null +++ b/tests/Fixtures/update_recipes/symfony.lock @@ -0,0 +1,14 @@ +{ + "symfony/console": { + "version": "5.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "5.1", + "ref": "c6d02bdfba9da13c22157520e32a602dbee8a75c" + }, + "files": [ + "bin/console" + ] + } +} diff --git a/tests/Update/DiffHelperTest.php b/tests/Update/DiffHelperTest.php new file mode 100644 index 000000000..9441f6d0f --- /dev/null +++ b/tests/Update/DiffHelperTest.php @@ -0,0 +1,331 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Update; + +use PHPUnit\Framework\TestCase; +use Symfony\Flex\Update\DiffHelper; + +class DiffHelperTest extends TestCase +{ + /** + * @dataProvider getRemoveFilesFromPatchTests + */ + public function testRemoveFilesFromPatch(string $patch, array $filesToRemove, string $expectedPatch, array $expectedRemovedPatches) + { + $removedPatches = []; + $this->assertSame($expectedPatch, DiffHelper::removeFilesFromPatch($patch, $filesToRemove, $removedPatches)); + + $this->assertSame($expectedRemovedPatches, $removedPatches); + } + + public function getRemoveFilesFromPatchTests(): iterable + { + $patch = << [ + $patch, + ['.env'], + << << [ + $patch, + ['config/packages/doctrine.yaml'], + << << [ + $patch, + ['config/packages/test/doctrine.yaml'], + << << [ + $patch, + ['config/packages/test/doctrine.yaml', 'config/packages/doctrine.yaml'], + << << << [ + $patch, + ['config/packages/test/doctrine.yaml', 'config/packages/doctrine.yaml', '.env', 'config/packages/prod/doctrine.yaml'], + '', + [ + 'config/packages/test/doctrine.yaml' => << << << << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Update; + +use PHPUnit\Framework\TestCase; +use Symfony\Flex\Update\RecipePatch; + +class RecipePatchTest extends TestCase +{ + public function testBasicFunctioning() + { + $thePatch = 'the patch'; + $blobs = ['blob1', 'blob2', 'beware of the blob']; + $removedPatches = ['foo' => 'some diff']; + + $patch = new RecipePatch($thePatch, $blobs, $removedPatches); + + $this->assertSame($thePatch, $patch->getPatch()); + $this->assertSame($blobs, $patch->getBlobs()); + $this->assertSame($removedPatches, $patch->getRemovedPatches()); + } +} diff --git a/tests/Update/RecipePatcherTest.php b/tests/Update/RecipePatcherTest.php new file mode 100644 index 000000000..90521b4d6 --- /dev/null +++ b/tests/Update/RecipePatcherTest.php @@ -0,0 +1,593 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Update; + +use Composer\IO\IOInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; +use Symfony\Flex\Update\RecipePatch; +use Symfony\Flex\Update\RecipePatcher; + +class RecipePatcherTest extends TestCase +{ + private $filesystem; + + protected function setUp(): void + { + $this->getFilesystem()->remove(FLEX_TEST_DIR); + $this->getFilesystem()->mkdir(FLEX_TEST_DIR); + } + + /** + * @dataProvider getGeneratePatchTests + */ + public function testGeneratePatch(array $originalFiles, array $newFiles, string $expectedPatch) + { + $this->getFilesystem()->remove(FLEX_TEST_DIR); + $this->getFilesystem()->mkdir(FLEX_TEST_DIR); + // original files need to be present to avoid patcher thinking they were deleting and skipping patch + foreach ($originalFiles as $file => $contents) { + touch(FLEX_TEST_DIR.'/'.$file); + } + + $patcher = new RecipePatcher(FLEX_TEST_DIR, $this->createMock(IOInterface::class)); + + $patch = $patcher->generatePatch($originalFiles, $newFiles); + $this->assertSame($expectedPatch, rtrim($patch->getPatch(), "\n")); + + // find all "index 7d30dc7.." in patch + $matches = []; + preg_match_all('/index\ ([0-9|a-z]+)\.\./', $patch->getPatch(), $matches); + $expectedBlobs = $matches[1]; + // new files (0000000) do not need a blob + $expectedBlobs = array_values(array_filter($expectedBlobs, function ($blob) { + return '0000000' !== $blob; + })); + + $actualShortenedBlobs = array_map(function ($blob) { + return substr($blob, 0, 7); + }, array_keys($patch->getBlobs())); + + $this->assertSame($expectedBlobs, $actualShortenedBlobs); + } + + public function getGeneratePatchTests(): iterable + { + yield 'updated_file' => [ + ['file1.txt' => 'Original contents', 'file2.txt' => 'Original file2'], + ['file1.txt' => 'Updated contents', 'file2.txt' => 'Updated file2'], + << [ + [], + ['file1.txt' => 'New file'], + << [ + ['file1.txt' => null], + ['file1.txt' => 'New file'], + << [ + ['file1.txt' => 'New file'], + [], + << [ + ['file1.txt' => 'New file'], + ['file1.txt' => null], + << [ + ['file1.txt' => 'Original file1', 'will_be_deleted.txt' => 'file to delete'], + ['file1.txt' => 'Updated file1', 'will_be_created.text' => 'file to create'], + <<getFilesystem()->remove(FLEX_TEST_DIR); + $this->getFilesystem()->mkdir(FLEX_TEST_DIR); + + $patcher = new RecipePatcher(FLEX_TEST_DIR, $this->createMock(IOInterface::class)); + + // try to update a file that does not exist in the project + $patch = $patcher->generatePatch(['.env' => 'original contents'], ['.env' => 'new contents']); + $this->assertSame('', $patch->getPatch()); + } + + /** + * @dataProvider getApplyPatchTests + */ + public function testApplyPatch(array $filesCurrentlyInApp, RecipePatch $recipePatch, array $expectedFiles, bool $expectedConflicts) + { + (new Process(['git', 'init'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'config', 'user.name', 'Unit test'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'config', 'user.email', ''], FLEX_TEST_DIR))->mustRun(); + + foreach ($filesCurrentlyInApp as $file => $contents) { + $path = FLEX_TEST_DIR.'/'.$file; + if (!file_exists(\dirname($path))) { + @mkdir(\dirname($path), 0777, true); + } + file_put_contents($path, $contents); + } + if (\count($filesCurrentlyInApp) > 0) { + (new Process(['git', 'add', '-A'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'commit', '-m', 'Committing original files'], FLEX_TEST_DIR))->mustRun(); + } + + $patcher = new RecipePatcher(FLEX_TEST_DIR, $this->createMock(IOInterface::class)); + $hadConflicts = !$patcher->applyPatch($recipePatch); + + foreach ($expectedFiles as $file => $expectedContents) { + if (null === $expectedContents) { + $this->assertFileDoesNotExist(FLEX_TEST_DIR.'/'.$file); + + continue; + } + $this->assertFileExists(FLEX_TEST_DIR.'/'.$file); + $this->assertSame($expectedContents, file_get_contents(FLEX_TEST_DIR.'/'.$file)); + } + + $this->assertSame($expectedConflicts, $hadConflicts); + } + + public function getApplyPatchTests(): iterable + { + $files = $this->getFilesForPatching(); + $dotEnvClean = $files['dot_env_clean']; + $packageJsonConflict = $files['package_json_conflict']; + $webpackEncoreAdded = $files['webpack_encore_added']; + $securityRemoved = $files['security_removed']; + + yield 'cleanly_patch_one_file' => [ + ['.env' => $dotEnvClean['in_app']], + new RecipePatch( + $dotEnvClean['patch'], + [$dotEnvClean['hash'] => $dotEnvClean['blob']] + ), + ['.env' => $dotEnvClean['expected']], + false, + ]; + + yield 'conflict_on_one_file' => [ + ['package.json' => $packageJsonConflict['in_app']], + new RecipePatch( + $packageJsonConflict['patch'], + [$packageJsonConflict['hash'] => $packageJsonConflict['blob']] + ), + ['package.json' => $packageJsonConflict['expected']], + true, + ]; + + yield 'add_one_new_file' => [ + // file is not currently in the app + [], + new RecipePatch( + $webpackEncoreAdded['patch'], + // no blobs needed for a new file + [] + ), + ['config/packages/webpack_encore.yaml' => $webpackEncoreAdded['expected']], + false, + ]; + + yield 'removed_one_file' => [ + ['config/packages/security.yaml' => $securityRemoved['in_app']], + new RecipePatch( + $securityRemoved['patch'], + [$securityRemoved['hash'] => $securityRemoved['blob']] + ), + // expected to be deleted + ['config/packages/security.yaml' => null], + false, + ]; + + yield 'complex_mixture' => [ + [ + '.env' => $dotEnvClean['in_app'], + 'package.json' => $packageJsonConflict['in_app'], + // webpack_encore.yaml not in starting project + 'config/packages/security.yaml' => $securityRemoved['in_app'], + ], + new RecipePatch( + $dotEnvClean['patch']."\n".$packageJsonConflict['patch']."\n".$webpackEncoreAdded['patch']."\n".$securityRemoved['patch'], + [ + $dotEnvClean['hash'] => $dotEnvClean['blob'], + $packageJsonConflict['hash'] => $packageJsonConflict['blob'], + $webpackEncoreAdded['hash'] => $webpackEncoreAdded['blob'], + $securityRemoved['hash'] => $securityRemoved['blob'], + ] + ), + [ + '.env' => $dotEnvClean['expected'], + 'package.json' => $packageJsonConflict['expected'], + 'config/packages/webpack_encore.yaml' => $webpackEncoreAdded['expected'], + 'config/packages/security.yaml' => null, + ], + true, + ]; + } + + /** + * @dataProvider getIntegrationTests + */ + public function testIntegration(bool $useNullForMissingFiles) + { + $files = $this->getFilesForPatching(); + (new Process(['git', 'init'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'config', 'user.name', 'Unit test'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'config', 'user.email', ''], FLEX_TEST_DIR))->mustRun(); + + $startingFiles = [ + '.env' => $files['dot_env_clean']['in_app'], + 'package.json' => $files['package_json_conflict']['in_app'], + // no webpack_encore.yaml in app + 'config/packages/security.yaml' => $files['security_removed']['in_app'], + // no cache.yaml in app - the update patch will be skipped + ]; + foreach ($startingFiles as $file => $contents) { + if (!file_exists(\dirname(FLEX_TEST_DIR.'/'.$file))) { + @mkdir(\dirname(FLEX_TEST_DIR.'/'.$file), 0777, true); + } + + file_put_contents(FLEX_TEST_DIR.'/'.$file, $contents); + } + // commit the files in the app + (new Process(['git', 'add', '-A'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'commit', '-m', 'committing in app start files'], FLEX_TEST_DIR))->mustRun(); + + $patcher = new RecipePatcher(FLEX_TEST_DIR, $this->createMock(IOInterface::class)); + $originalFiles = [ + '.env' => $files['dot_env_clean']['original_recipe'], + 'package.json' => $files['package_json_conflict']['original_recipe'], + 'config/packages/security.yaml' => $files['security_removed']['original_recipe'], + 'config/packages/cache.yaml' => 'original cache.yaml', + ]; + if ($useNullForMissingFiles) { + $originalFiles['config/packages/webpack_encore.yaml'] = null; + } + + $updatedFiles = [ + '.env' => $files['dot_env_clean']['updated_recipe'], + 'package.json' => $files['package_json_conflict']['updated_recipe'], + 'config/packages/webpack_encore.yaml' => $files['webpack_encore_added']['updated_recipe'], + 'config/packages/cache.yaml' => 'updated cache.yaml', + ]; + if ($useNullForMissingFiles) { + $updatedFiles['config/packages/security.yaml'] = null; + } + + $recipePatch = $patcher->generatePatch($originalFiles, $updatedFiles); + $appliedCleanly = $patcher->applyPatch($recipePatch); + + $this->assertFalse($appliedCleanly); + $this->assertSame($files['dot_env_clean']['expected'], file_get_contents(FLEX_TEST_DIR.'/.env')); + $this->assertSame($files['package_json_conflict']['expected'], file_get_contents(FLEX_TEST_DIR.'/package.json')); + $this->assertSame($files['webpack_encore_added']['expected'], file_get_contents(FLEX_TEST_DIR.'/config/packages/webpack_encore.yaml')); + $this->assertFileDoesNotExist(FLEX_TEST_DIR.'/security.yaml'); + } + + public function getIntegrationTests(): iterable + { + yield 'missing_files_set_to_null' => [true]; + yield 'missing_files_not_in_array' => [false]; + } + + /** + * Returns files with keys: + * * filename + * * in_app: Contents the file currently has in the app + * * patch The diff/patch to apply to the file + * * expected The expected final contents + * * hash hash-object used for the blob address + * * blob The raw file blob of the original recipe contents + * * original_recipe + * * updated_recipe. + */ + private function getFilesForPatching(): array + { + $files = [ + // .env + 'dot_env_clean' => [ + 'filename' => '.env', + 'original_recipe' => << symfony/framework-bundle ### +APP_ENV=dev +APP_SECRET=cd0019c56963e76bacd77eee67e1b222 +###< symfony/framework-bundle ### + +###> doctrine/doctrine-bundle ### +# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url +# For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db" +DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db +###< doctrine/doctrine-bundle ### +EOF + , 'updated_recipe' => << symfony/framework-bundle ### +APP_ENV=dev +APP_SECRET=cd0019c56963e76bacd77eee67e1b222 +###< symfony/framework-bundle ### + +###> doctrine/doctrine-bundle ### +# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url +# For an SQL-HEAVY database, use: "sqlheavy:///%kernel.project_dir%/var/data.db" +DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db +###< doctrine/doctrine-bundle ### +EOF + , 'in_app' => << symfony/framework-bundle ### +APP_ENV=CHANGED_TO_PROD_ENVIRONMENT +APP_SECRET=cd0019c56963e76bacd77eee67e1b222 +###< symfony/framework-bundle ### + +###> doctrine/doctrine-bundle ### +# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url +# For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db" +DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db +###< doctrine/doctrine-bundle ### +EOF + , 'expected' => << symfony/framework-bundle ### +APP_ENV=CHANGED_TO_PROD_ENVIRONMENT +APP_SECRET=cd0019c56963e76bacd77eee67e1b222 +###< symfony/framework-bundle ### + +###> doctrine/doctrine-bundle ### +# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url +# For an SQL-HEAVY database, use: "sqlheavy:///%kernel.project_dir%/var/data.db" +DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db +###< doctrine/doctrine-bundle ### +EOF + ], + + // package.json + 'package_json_conflict' => [ + 'filename' => 'package.json', + 'original_recipe' => << << << <<>>>>>> theirs + "@symfony/stimulus-bridge": "^3.0.0", + "@symfony/webpack-encore": "^1.7.0" + } +} +EOF + ], + + // config/packages/webpack_encore.yaml + 'webpack_encore_added' => [ + 'filename' => 'config/packages/webpack_encore.yaml', + 'original_recipe' => null, + 'updated_recipe' => << null, + 'expected' => << [ + 'filename' => 'config/packages/security.yaml', + 'original_recipe' => << null, + 'in_app' => << null, + ], + ]; + + // calculate the patch, hash & blob from the files for convenience + // (it's easier than storing those in the test directly) + foreach ($files as $key => $data) { + $files[$key] = array_merge( + $data, + $this->generatePatchData($data['filename'], $data['original_recipe'], $data['updated_recipe']) + ); + } + + return $files; + } + + private function generatePatchData(string $filename, ?string $start, ?string $end): array + { + $dir = sys_get_temp_dir().'/_flex_diff'; + if (file_exists($dir)) { + $this->getFilesystem()->remove($dir); + } + @mkdir($dir); + (new Process(['git', 'init'], $dir))->mustRun(); + (new Process(['git', 'config', 'user.name', 'Unit test'], $dir))->mustRun(); + (new Process(['git', 'config', 'user.email', ''], $dir))->mustRun(); + + if (!file_exists(\dirname($dir.'/'.$filename))) { + @mkdir(\dirname($dir.'/'.$filename), 0777, true); + } + + $hash = null; + $blob = null; + if (null !== $start) { + file_put_contents($dir.'/'.$filename, $start); + (new Process(['git', 'add', '-A'], $dir))->mustRun(); + (new Process(['git', 'commit', '-m', 'adding file'], $dir))->mustRun(); + + $process = (new Process(['git', 'hash-object', $filename], $dir))->mustRun(); + $hash = trim($process->getOutput()); + + $hashStart = substr($hash, 0, 2); + $hashEnd = substr($hash, 2); + $blob = file_get_contents($dir.'/.git/objects/'.$hashStart.'/'.$hashEnd); + } + + if (null === $end) { + unlink($dir.'/'.$filename); + } else { + file_put_contents($dir.'/'.$filename, $end); + } + (new Process(['git', 'add', '-A'], $dir))->mustRun(); + $process = (new Process(['git', 'diff', '--cached'], $dir))->mustRun(); + $diff = $process->getOutput(); + + return [ + 'patch' => $diff, + 'hash' => $hash, + 'blob' => $blob, + ]; + } + + private function getFilesystem(): Filesystem + { + if (null === $this->filesystem) { + $this->filesystem = new Filesystem(); + } + + return $this->filesystem; + } +} diff --git a/tests/Update/RecipeUpdateTest.php b/tests/Update/RecipeUpdateTest.php new file mode 100644 index 000000000..ada8f0901 --- /dev/null +++ b/tests/Update/RecipeUpdateTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Update; + +use PHPUnit\Framework\TestCase; +use Symfony\Flex\Lock; +use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; + +class RecipeUpdateTest extends TestCase +{ + private $originalRecipe; + private $newRecipe; + private $lock; + private $rootDir; + private $update; + + protected function setUp(): void + { + $this->originalRecipe = $this->createMock(Recipe::class); + $this->newRecipe = $this->createMock(Recipe::class); + $this->lock = new Lock('lock_file'); + $this->rootDir = '/path/to/here'; + $this->update = new RecipeUpdate($this->originalRecipe, $this->newRecipe, $this->lock, $this->rootDir); + } + + public function testGetters() + { + $this->assertSame($this->originalRecipe, $this->update->getOriginalRecipe()); + $this->assertSame($this->newRecipe, $this->update->getNewRecipe()); + $this->assertSame($this->lock, $this->update->getLock()); + $this->assertSame($this->rootDir, $this->update->getRootDir()); + } + + public function testOriginalFiles() + { + $this->update->setOriginalFile('file1', 'file1_contents'); + $this->update->addOriginalFiles([ + 'file2' => 'file2_contents', + 'file3' => 'file3_contents', + ]); + + $this->assertSame( + ['file1' => 'file1_contents', 'file2' => 'file2_contents', 'file3' => 'file3_contents'], + $this->update->getOriginalFiles() + ); + } + + public function testNewFiles() + { + $this->update->setNewFile('file1', 'file1_contents'); + $this->update->addNewFiles([ + 'file2' => 'file2_contents', + 'file3' => 'file3_contents', + ]); + + $this->assertSame( + ['file1' => 'file1_contents', 'file2' => 'file2_contents', 'file3' => 'file3_contents'], + $this->update->getNewFiles() + ); + } +}