diff --git a/src/Configurator/CopyFromRecipeConfigurator.php b/src/Configurator/CopyFromRecipeConfigurator.php index 004c0653..0b84f6cd 100644 --- a/src/Configurator/CopyFromRecipeConfigurator.php +++ b/src/Configurator/CopyFromRecipeConfigurator.php @@ -31,7 +31,7 @@ public function configure(Recipe $recipe, $config, Lock $lock, array $options = public function unconfigure(Recipe $recipe, $config, Lock $lock) { $this->write('Removing files from recipe'); - $this->removeFiles($config, $this->getRemovableFilesFromRecipeAndLock($recipe, $lock), $this->options->get('root-dir')); + $this->removeFiles($config, $this->options->getRemovableFilesFromRecipeAndLock($recipe), $this->options->get('root-dir')); } public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void @@ -66,32 +66,6 @@ private function resolveTargetFolder(string $path, array $config): string return $path; } - private function getRemovableFilesFromRecipeAndLock(Recipe $recipe, Lock $lock): array - { - $lockedFiles = array_unique( - array_reduce( - array_column($lock->all(), 'files'), - function (array $carry, array $package) { - return array_merge($carry, $package); - }, - [] - ) - ); - - $removableFiles = $recipe->getFiles(); - - $lockedFiles = array_map('realpath', $lockedFiles); - - // Compare file paths by their real path to abstract OS differences - foreach (array_keys($removableFiles) as $file) { - if (\in_array(realpath($file), $lockedFiles)) { - unset($removableFiles[$file]); - } - } - - return $removableFiles; - } - private function copyFiles(array $manifest, array $files, array $options): array { $copiedFiles = []; diff --git a/src/FilesManager.php b/src/FilesManager.php new file mode 100644 index 00000000..f9792ec9 --- /dev/null +++ b/src/FilesManager.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex; + +use Composer\IO\IOInterface; +use Composer\Util\ProcessExecutor; + +/** + * @author Maxime Hélias + */ +class FilesManager +{ + private $io; + protected $path; + + private $writtenFiles = []; + private $files; + + public function __construct(IOInterface $io, Lock $lock, string $rootDir) + { + $this->io = $io; + + $this->path = new Path($rootDir); + $this->files = array_count_values( + array_map( + function (string $file) { + return realpath($file) ?: ''; + }, array_reduce( + array_column($lock->all(), 'files'), + function (array $carry, array $package) { + return array_merge($carry, $package); + }, + [] + ) + ) + ); + } + + public function shouldWriteFile(string $file, bool $overwrite, bool $skipQuestion): bool + { + if (isset($this->writtenFiles[$file])) { + return false; + } + $this->writtenFiles[$file] = true; + + if (!file_exists($file)) { + return true; + } + + if (!$overwrite) { + return false; + } + + if (!filesize($file)) { + return true; + } + + if ($skipQuestion) { + return true; + } + + exec('git status --short --ignored --untracked-files=all -- '.ProcessExecutor::escape($file).' 2>&1', $output, $status); + + if (0 !== $status) { + return $this->io->askConfirmation(\sprintf('Cannot determine the state of the "%s" file, overwrite anyway? [y/N] ', $file), false); + } + + if (empty($output[0]) || preg_match('/^[ AMDRCU][ D][ \t]/', $output[0])) { + return true; + } + + $name = basename($file); + $name = \strlen($output[0]) - \strlen($name) === strrpos($output[0], $name) ? substr($output[0], 3) : $name; + + return $this->io->askConfirmation(\sprintf('File "%s" has uncommitted changes, overwrite? [y/N] ', $name), false); + } + + public function getRemovableFilesFromRecipeAndLock(Recipe $recipe): array + { + $removableFiles = $recipe->getFiles(); + // Compare file paths by their real path to abstract OS differences + foreach (array_keys($removableFiles) as $file) { + $file = realpath($file); + if (!isset($this->files[$file])) { + continue; + } + + --$this->files[$file]; + + if ($this->files[$file] <= 0) { + unset($removableFiles[$file]); + } + } + + return $removableFiles; + } +} diff --git a/src/Flex.php b/src/Flex.php index b7d10209..962572af 100644 --- a/src/Flex.php +++ b/src/Flex.php @@ -111,7 +111,13 @@ class_exists(__NAMESPACE__.str_replace('/', '\\', substr($file, \strlen(__DIR__) $this->composer = $composer; $this->io = $io; $this->config = $composer->getConfig(); - $this->options = $this->initOptions(); + + $composerFile = Factory::getComposerFile(); + $composerLock = 'json' === pathinfo($composerFile, \PATHINFO_EXTENSION) ? substr($composerFile, 0, -4).'lock' : $composerFile.'.lock'; + $symfonyLock = str_replace('composer', 'symfony', basename($composerLock)); + $this->lock = new Lock(getenv('SYMFONY_LOCKFILE') ?: \dirname($composerLock).'/'.(basename($composerLock) !== $symfonyLock ? $symfonyLock : 'symfony.lock')); + + $this->options = $this->initOptions($this->io, $this->lock); // if Flex is being upgraded, the original operations from the original Flex // instance are stored in the static property, so we can reuse them now. @@ -130,12 +136,7 @@ class_exists(__NAMESPACE__.str_replace('/', '\\', substr($file, \strlen(__DIR__) $this->filter = new PackageFilter($io, $symfonyRequire, $this->downloader); } - $composerFile = Factory::getComposerFile(); - $composerLock = 'json' === pathinfo($composerFile, \PATHINFO_EXTENSION) ? substr($composerFile, 0, -4).'lock' : $composerFile.'.lock'; - $symfonyLock = str_replace('composer', 'symfony', basename($composerLock)); - $this->configurator = new Configurator($composer, $io, $this->options); - $this->lock = new Lock(getenv('SYMFONY_LOCKFILE') ?: \dirname($composerLock).'/'.(basename($composerLock) !== $symfonyLock ? $symfonyLock : 'symfony.lock')); $disable = true; foreach (array_merge($composer->getPackage()->getRequires() ?? [], $composer->getPackage()->getDevRequires() ?? []) as $link) { @@ -701,7 +702,7 @@ public function getLock(): Lock return $this->lock; } - private function initOptions(): Options + private function initOptions(IOInterface $io, Lock $lock): Options { $extra = $this->composer->getPackage()->getExtra(); @@ -716,7 +717,7 @@ private function initOptions(): Options 'runtime' => $extra['runtime'] ?? [], ], $extra); - return new Options($options, $this->io); + return new Options($options, new FilesManager($io, $lock, $options['root-dir'])); } private function formatOrigin(Recipe $recipe): string diff --git a/src/Options.php b/src/Options.php index ee0bb3b3..0c5fcd5d 100644 --- a/src/Options.php +++ b/src/Options.php @@ -11,22 +11,18 @@ namespace Symfony\Flex; -use Composer\IO\IOInterface; -use Composer\Util\ProcessExecutor; - /** * @author Fabien Potencier */ class Options { private $options; - private $writtenFiles = []; - private $io; + private $filesManager; - public function __construct(array $options = [], ?IOInterface $io = null) + public function __construct(array $options = [], ?FilesManager $filesManager = null) { $this->options = $options; - $this->io = $io; + $this->filesManager = $filesManager; } public function get(string $name) @@ -64,41 +60,20 @@ public function expandTargetDir(string $target): string public function shouldWriteFile(string $file, bool $overwrite, bool $skipQuestion): bool { - if (isset($this->writtenFiles[$file])) { - return false; - } - $this->writtenFiles[$file] = true; - - if (!file_exists($file)) { - return true; - } - - if (!$overwrite) { + if (null === $this->filesManager) { return false; } - if (!filesize($file)) { - return true; - } - - if ($skipQuestion) { - return true; - } - - exec('git status --short --ignored --untracked-files=all -- '.ProcessExecutor::escape($file).' 2>&1', $output, $status); - - if (0 !== $status) { - return $this->io && $this->io->askConfirmation(\sprintf('Cannot determine the state of the "%s" file, overwrite anyway? [y/N] ', $file), false); - } + return $this->filesManager->shouldWriteFile($file, $overwrite, $skipQuestion); + } - if (empty($output[0]) || preg_match('/^[ AMDRCU][ D][ \t]/', $output[0])) { - return true; + public function getRemovableFilesFromRecipeAndLock(Recipe $recipe): array + { + if (null === $this->filesManager) { + return []; } - $name = basename($file); - $name = \strlen($output[0]) - \strlen($name) === strrpos($output[0], $name) ? substr($output[0], 3) : $name; - - return $this->io && $this->io->askConfirmation(\sprintf('File "%s" has uncommitted changes, overwrite? [y/N] ', $name), false); + return $this->filesManager->getRemovableFilesFromRecipeAndLock($recipe); } public function toArray(): array diff --git a/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php b/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php index 43f5f5de..633a2865 100644 --- a/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php +++ b/tests/Configurator/CopyDirectoryFromPackageConfiguratorTest.php @@ -194,7 +194,7 @@ protected function tearDown(): void private function createConfigurator(): CopyFromPackageConfigurator { - return new CopyFromPackageConfigurator($this->composer, $this->io, new Options(['root-dir' => FLEX_TEST_DIR], $this->io)); + return new CopyFromPackageConfigurator($this->composer, $this->io, new Options(['root-dir' => FLEX_TEST_DIR])); } private function cleanUpTargetFiles() diff --git a/tests/Configurator/CopyFromPackageConfiguratorTest.php b/tests/Configurator/CopyFromPackageConfiguratorTest.php index 8a5366b3..83a689db 100644 --- a/tests/Configurator/CopyFromPackageConfiguratorTest.php +++ b/tests/Configurator/CopyFromPackageConfiguratorTest.php @@ -168,7 +168,7 @@ protected function tearDown(): void private function createConfigurator(): CopyFromPackageConfigurator { - return new CopyFromPackageConfigurator($this->composer, $this->io, new Options(['root-dir' => FLEX_TEST_DIR], $this->io)); + return new CopyFromPackageConfigurator($this->composer, $this->io, new Options(['root-dir' => FLEX_TEST_DIR])); } private function cleanUpTargetFiles() diff --git a/tests/Configurator/CopyFromRecipeConfiguratorTest.php b/tests/Configurator/CopyFromRecipeConfiguratorTest.php index 95f01656..6cfb2fc9 100644 --- a/tests/Configurator/CopyFromRecipeConfiguratorTest.php +++ b/tests/Configurator/CopyFromRecipeConfiguratorTest.php @@ -301,7 +301,7 @@ protected function tearDown(): void private function createConfigurator(): CopyFromRecipeConfigurator { - return new CopyFromRecipeConfigurator($this->getMockBuilder(Composer::class)->getMock(), $this->io, new Options(['root-dir' => FLEX_TEST_DIR, 'config-dir' => 'config'], $this->io)); + return new CopyFromRecipeConfigurator($this->getMockBuilder(Composer::class)->getMock(), $this->io, new Options(['root-dir' => FLEX_TEST_DIR, 'config-dir' => 'config'])); } private function cleanUpTargetFiles() diff --git a/tests/FilesManagerTest.php b/tests/FilesManagerTest.php new file mode 100644 index 00000000..a0b69705 --- /dev/null +++ b/tests/FilesManagerTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests; + +use Composer\IO\IOInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Process; +use Symfony\Flex\FilesManager; +use Symfony\Flex\Lock; + +class FilesManagerTest extends TestCase +{ + public function testShouldWrite() + { + @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(); + + $filePath = FLEX_TEST_DIR.'/a.txt'; + file_put_contents($filePath, 'a'); + (new Process(['git', 'add', '-A'], FLEX_TEST_DIR))->mustRun(); + (new Process(['git', 'commit', '-m', 'setup of original files'], FLEX_TEST_DIR))->mustRun(); + + file_put_contents($filePath, 'b'); + + $io = $this->getMockBuilder(IOInterface::class)->getMock(); + $io->method('askConfirmation')->willReturn(true); + + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + $lock->method('all')->willReturn([ + 'symfony/my-package' => [ + 'files' => [ + $filePath, + ], + ], + ]); + + $filesManager = new FilesManager($io, $lock, FLEX_TEST_DIR); + + // We need to set the writtenFiles property to reset the state + $reflection = new \ReflectionProperty(FilesManager::class, 'writtenFiles'); + $reflection->setAccessible(true); + + $this->assertTrue($filesManager->shouldWriteFile('non-existing-file.txt', false, false)); + $this->assertFalse($filesManager->shouldWriteFile($filePath, false, false)); + + // It allowed to write the file + $reflection->setValue($filesManager, []); + $this->assertTrue($filesManager->shouldWriteFile($filePath, true, false)); + + // We skip all questions, so we're able to write + $reflection->setValue($filesManager, []); + $this->assertTrue($filesManager->shouldWriteFile($filePath, true, true)); + } +} diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php deleted file mode 100644 index 1064fcbf..00000000 --- a/tests/OptionsTest.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Flex\Tests; - -use Composer\IO\IOInterface; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Process\Process; -use Symfony\Flex\Options; - -class OptionsTest extends TestCase -{ - public function testShouldWrite() - { - @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(); - - $filePath = FLEX_TEST_DIR.'/a.txt'; - file_put_contents($filePath, 'a'); - (new Process(['git', 'add', '-A'], FLEX_TEST_DIR))->mustRun(); - (new Process(['git', 'commit', '-m', 'setup of original files'], FLEX_TEST_DIR))->mustRun(); - - file_put_contents($filePath, 'b'); - - $this->assertTrue((new Options([], null))->shouldWriteFile('non-existing-file.txt', false, false)); - $this->assertFalse((new Options([], null))->shouldWriteFile($filePath, false, false)); - - // We don't have an IO, so we don't write the file - $this->assertFalse((new Options([], null))->shouldWriteFile($filePath, true, false)); - - // We have an IO, and it allowed to write the file - $io = $this->createMock(IOInterface::class); - $io->expects($this->once())->method('askConfirmation')->willReturn(true); - $this->assertTrue((new Options([], $io))->shouldWriteFile($filePath, true, false)); - - // We skip all questions, so we're able to write - $this->assertTrue((new Options([], null))->shouldWriteFile($filePath, true, true)); - } -}