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