diff --git a/composer/Plugin/Scaffold/Handler.php b/composer/Plugin/Scaffold/Handler.php index e96b6f3820..ce4ca80820 100644 --- a/composer/Plugin/Scaffold/Handler.php +++ b/composer/Plugin/Scaffold/Handler.php @@ -161,6 +161,23 @@ public function scaffold() { // take priority and which are conjoined. $scaffold_files = new ScaffoldFileCollection($file_mappings, $location_replacements); + // Check to see if there are any scaffold files from a previous run that + // have been modified. + $hash_manager = HashManager::create($this->io, $this->composer->getConfig()->get('vendor-dir'), getcwd()); + + // Get the scaffold files that have been modified since they were written. + $modified = $hash_manager->getModified($scaffold_files); + + // Ask the user what to do about the modified files (if any). + // Prompt the user and ask what to do about modified files. + $modified = $hash_manager->decideHowToHandleModified($modified); + + // Remove the modified files if so instructed by the user. If nothing was + // modified, or the user did not want to keep any files, then this list + // will be empty. + $message = ' - Preserve [dest-rel-path]: modified by user.'; + $scaffold_files->skipFiles($modified, $message); + // Process the list of scaffolded files. $scaffold_results = ScaffoldFileCollection::process($scaffold_files, $this->io, $scaffold_options); @@ -178,6 +195,9 @@ public function scaffold() { // Call post-scaffold scripts. $dispatcher->dispatch(self::POST_DRUPAL_SCAFFOLD_CMD); + + // Save a hash for all of the scaffold result files. + $hash_manager->storeResultsHash($scaffold_results); } /** diff --git a/composer/Plugin/Scaffold/HashManager.php b/composer/Plugin/Scaffold/HashManager.php new file mode 100644 index 0000000000..b8e0e00338 --- /dev/null +++ b/composer/Plugin/Scaffold/HashManager.php @@ -0,0 +1,282 @@ +io = $io; + $this->vendorDir = $vendor_dir; + $this->dir = $dir; + } + + /** + * Creates a HashManager. + * + * @param \Composer\IO\IOInterface $io + * The input/output object. + * @param string $vendor_dir + * The location of the vendor directory. + * @param string $dir + * The directory where the project is located. + * + * @return static + * A loaded hash manager. + */ + public static function create(IOInterface $io, $vendor_dir, $dir) { + $hash_manager = new static($io, $vendor_dir, $dir); + $hash_path = $hash_manager->pathToHashCache(); + if (file_exists($hash_path)) { + $hash_manager->hashCache = json_decode(file_get_contents($hash_path), TRUE); + } + return $hash_manager; + } + + /** + * Returns the list of scaffold files which have been modified. + * + * If a scaffold file exists on disk, and it also has an entry in the hash + * manager (because it was scaffolded on a previous run), then we will check + * to see if its hash value has changed. Any file with a modified hash value + * will be included in the result of this method. + * + * @param \Drupal\Composer\Plugin\Scaffold\Operations\ScaffoldFileCollection $scaffold_files + * Collection of destination paths. + * + * @return string[] + * List of modified files. + */ + public function getModified(ScaffoldFileCollection $scaffold_files) { + // Iterate over our scaffold files and determine if any were modified. + $checked = []; + $modified = []; + foreach ($scaffold_files as $project_name => $project_files) { + foreach ($project_files as $destination_rel_path => $scaffold_file) { + if (!in_array($destination_rel_path, $checked)) { + $checked[] = $destination_rel_path; + if ($this->checkModified($scaffold_file->destination())) { + $modified[] = $destination_rel_path; + } + } + } + } + + // Let the caller know what the user decided to keep. + return $modified; + } + + /** + * Checks to see if a single scaffold file has been modified on disk. + * + * If the file existed on the last run and its hash has changed, then add it + * to the list of modified files. + * + * @param \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath $scaffold_file + * One scaffold file that is about to be placed. + * + * @return bool + * True if file has been modified since it was scaffolded. + */ + protected function checkModified(ScaffoldFilePath $scaffold_file) { + $relative = $scaffold_file->relativePath(); + if (!isset($this->hashCache[$relative])) { + return FALSE; + } + $hash = $this->hashFile($scaffold_file); + if (empty($hash)) { + return FALSE; + } + return $hash != $this->hashCache[$relative]; + } + + /** + * Asks the user how to handle modified files. + * + * As a side effect of this function, the root-level composer.json file will + * be modified if the user selects the "keep" option. + * + * @param string[] $modified + * An array of destination paths that were modified. + * + * @return string[] + * An array of modified that should not be preserved. + */ + public function decideHowToHandleModified($modified) { + // Nothing modified? Nothing to do. + if (empty($modified)) { + return []; + } + + // Show the user which files are in danger of being overwritten. + $show_modified = implode("\n", array_map(function ($item) { + return " - $item"; + }, $modified)); + $this->io->writeError("The following managed scaffold files have been modified:\n$show_modified\n"); + + // Ask the user what they want to do + while (TRUE) { + $default = $this->defaultAnswerForModifiedPrompt(); + + // @todo: Composer also offers 'v' (view) and 'd' (diff); we could add + // 'view' as a follow-on task, but 'diff' would be difficult here. + switch ($this->io->ask(' Discard changes [y,n,k,a,?]? ', $default)) { + case 'y': + return []; + + case 'n': + return $modified; + + case 'k': + $this->overrideModifiedInComposerJson($modified); + return $modified; + + case 'a': + throw new \RuntimeException('Scaffold aborted'); + + case '?': + default: + $this->io->writeError([ + ' y - discard changes and rewrite scaffold files', + ' n - keep modified files in their current state; ask again the next time Composer runs', + ' k - keep modified files and modify composer.json to avoid being asked again', + ' a - abort scaffold operation', + ' For information on how to manage scaffold modifications, see:', + ' https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold', + ]); + } + } + } + + /** + * Determines what our default action should be for prompt. + * + * The default value is returned automatically in non-interactive mode, or + * if the user hits [RETURN] without making a selection. + * + * @return string + * Default value to return from 'modified' prompt. + */ + protected function defaultAnswerForModifiedPrompt() { + // Interactive? Ignore environment variables and always default to '?'. + if ($this->io->isInteractive()) { + return '?'; + } + // Keep modified files and don't ask again. + if (getenv('DRUPAL_SCAFFOLD_KEEP_MODIFIED')) { + return 'k'; + } + // Discard modified if instructed. + if (getenv('DRUPAL_SCAFFOLD_DISCARD_MODIFIED')) { + return 'y'; + } + // Default is preserve modified files and ask again next time. + return 'n'; + } + + /** + * Modifies composer.json to preserve modified files on subsequent runs. + * + * @param array $modified + * List of modified packages to disable in the composer.json file mappings. + */ + protected function overrideModifiedInComposerJson(array $modified) { + $composer_json_file = new JsonFile($this->dir . '/composer.json'); + + $composer_json_data = $composer_json_file->read(); + foreach ($modified as $keepPackage) { + $composer_json_data['extra']['drupal-scaffold']['file-mapping'][$keepPackage] = FALSE; + } + $composer_json_file->write($composer_json_data); + } + + /** + * Stores the provided list of file paths to the hash cache file. + * + * @param \Drupal\Composer\Plugin\Scaffold\Operations\ScaffoldResult[] $files + * A list of scaffold results, each of which holds a path. + */ + public function storeResultsHash(array $files) { + foreach ($files as $scaffold_result) { + if ($scaffold_result->isManaged()) { + $hash = $this->hashFile($scaffold_result->destination()); + $relative = $scaffold_result->destination()->relativePath(); + $this->hashCache[$relative] = $hash; + } + } + $hash_path = $this->pathToHashCache(); + file_put_contents($hash_path, json_encode($this->hashCache)); + } + + /** + * Gets the path to the file where the list of hashes is stored. + * + * @return string + * Path where the list of scaffolded files and their hashes are stored. + */ + protected function pathToHashCache() { + return "{$this->vendorDir}/drupal/scaffolded.json"; + } + + /** + * Calculates the on-disk hash value of a scaffold file. + * + * @param \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath $scaffold_file + * Path to the scaffold file, at its destination path. + * + * @return string + * Hash value. + */ + protected function hashFile(ScaffoldFilePath $scaffold_file) { + $path = $scaffold_file->fullPath(); + if (!file_exists($path)) { + return ''; + } + return sha1_file($path); + } + +} diff --git a/composer/Plugin/Scaffold/Operations/ScaffoldFileCollection.php b/composer/Plugin/Scaffold/Operations/ScaffoldFileCollection.php index c000e64a47..bff67dd32c 100644 --- a/composer/Plugin/Scaffold/Operations/ScaffoldFileCollection.php +++ b/composer/Plugin/Scaffold/Operations/ScaffoldFileCollection.php @@ -72,11 +72,33 @@ public function __construct(array $file_mappings, Interpolator $location_replace } } + /** + * Filter out any item in the list whose destination path has been modified. + * + * @param array $files_to_skip + * List of destination paths + * @param string $message + * Explanation of why files were skipped + */ + public function skipFiles($files_to_skip, $message) { + $op = new SkipOp($message); + + // If we were instructed to keep modified files, then filter them out of our + // list of scaffold files to process. + foreach ($this->scaffoldFilesByProject as $project_name => $scaffold_files) { + foreach ($scaffold_files as $destination_rel_path => $scaffold_file) { + if (in_array($destination_rel_path, $files_to_skip)) { + $this->scaffoldFilesByProject[$project_name][$destination_rel_path] = new ScaffoldFileInfo($scaffold_file->destination(), $op); + } + } + } + } + /** * {@inheritdoc} */ public function getIterator() { - return new \RecursiveArrayIterator($this->scaffoldFilesByProject, \RecursiveArrayIterator::CHILD_ARRAYS_ONLY); + return new \ArrayIterator($this->scaffoldFilesByProject); } /** diff --git a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php index c8e18e1b03..870f5703c7 100644 --- a/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ComposerHookTest.php @@ -26,16 +26,6 @@ class ComposerHookTest extends TestCase { use ExecTrait; use AssertUtilsTrait; - /** - * The root of this project. - * - * Used to substitute this project's base directory into composer.json files - * so Composer can find it. - * - * @var string - */ - protected $projectRoot; - /** * Directory to perform the tests in. * @@ -64,7 +54,9 @@ protected function setUp() { $this->fileSystem = new Filesystem(); $this->fixtures = new Fixtures(); $this->fixtures->createIsolatedComposerCacheDir(); - $this->projectRoot = $this->fixtures->projectRoot(); + $this->fixturesDir = $this->fixtures->tmpDir($this->getName()); + $replacements = ['SYMLINK' => 'false', 'PROJECT_ROOT' => $this->fixtures->projectRoot()]; + $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements); } /** @@ -76,46 +68,173 @@ protected function tearDown() { } /** - * Test to see if scaffold operation runs at the correct times. + * Test to see if scaffold operation runs after 'composer require'. */ - public function testComposerHooks() { - $this->fixturesDir = $this->fixtures->tmpDir($this->getName()); - $replacements = ['SYMLINK' => 'false', 'PROJECT_ROOT' => $this->projectRoot]; - $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements); + public function testRequireHooks() { $topLevelProjectDir = 'composer-hooks-fixture'; $sut = $this->fixturesDir . '/' . $topLevelProjectDir; + // First test: run composer install. This is the same as composer update // since there is no lock file. Ensure that scaffold operation ran. $this->mustExec("composer install --no-ansi", $sut); $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + // Run composer required to add in the scaffold-override-fixture. This // project is "allowed" in our main fixture project, but not required. // We expect that requiring this library should re-scaffold, resulting // in a changed default.settings.php file. $stdout = $this->mustExec("composer require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut); $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture'); + // Make sure that the appropriate notice informing us that scaffolding // is allowed was printed. $this->assertContains('Package fixtures/scaffold-override-fixture has scaffold operations, and is already allowed in the root-level composer.json file.', $stdout); + } + + /** + * Tests to see if deleted / modified files are handled correctly. + */ + public function testDeletedAndModifiedScaffoldFiles() { + $topLevelProjectDir = 'composer-hooks-fixture'; + $sut = $this->fixturesDir . '/' . $topLevelProjectDir; + + // Run 'composer install' again to set up for our next set of tests. + $this->mustExec("composer install --no-ansi", $sut); + // Delete one scaffold file, just for test purposes, then run // 'composer update' and see if the scaffold file is replaced. @unlink($sut . '/sites/default/default.settings.php'); $this->assertFileNotExists($sut . '/sites/default/default.settings.php'); $this->mustExec("composer update --no-ansi", $sut); - $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture'); + $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + // Delete the same test scaffold file again, then run // 'composer drupal:scaffold' and see if the scaffold file is // re-scaffolded. @unlink($sut . '/sites/default/default.settings.php'); $this->assertFileNotExists($sut . '/sites/default/default.settings.php'); $this->mustExec("composer install --no-ansi", $sut); - $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture'); + $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + // Delete the same test scaffold file yet again, then run // 'composer install' and see if the scaffold file is re-scaffolded. @unlink($sut . '/sites/default/default.settings.php'); $this->assertFileNotExists($sut . '/sites/default/default.settings.php'); $this->mustExec("composer drupal:scaffold --no-ansi", $sut); - $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture'); + $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + + // Modify the same test scaffold file, then run 'composer drupal:scaffold' + // and confirm the modification is not overwritten. + $contents = file_get_contents($sut . '/sites/default/default.settings.php'); + file_put_contents($sut . '/sites/default/default.settings.php', $contents . "\n// Simulated user modification\n"); + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut); + $this->assertContains("The following managed scaffold files have been modified:\n - [web-root]/sites/default/default.settings.php", $stdout); + $post_scaffold_contents = file_get_contents($sut . '/sites/default/default.settings.php'); + $this->assertContains('Simulated user modification', $post_scaffold_contents); + + // Run 'composer drupal:scaffold' twice in a row to make sure that the + // hash file of the modified file is retained, such that drupal:scaffold + // still recognizes that the modified file is modified. + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut); + $this->assertContains("The following managed scaffold files have been modified:\n - [web-root]/sites/default/default.settings.php", $stdout); + $post_scaffold_contents_two = file_get_contents($sut . '/sites/default/default.settings.php'); + $this->assertContains('Simulated user modification', $post_scaffold_contents_two); + + // Finally, run 'composer drupal:scaffold' yet a third time in a row, this + // time with an environment variable set that tells drupal:scaffold to act + // as if the user had requested that modified files be overwritten. + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut, ['DRUPAL_SCAFFOLD_DISCARD_MODIFIED' => 1]); + $this->assertContains("The following managed scaffold files have been modified:\n - [web-root]/sites/default/default.settings.php", $stdout); + $post_scaffold_contents_three = file_get_contents($sut . '/sites/default/default.settings.php'); + $this->assertNotContains('Simulated user modification', $post_scaffold_contents_three); + + // And if we run it yet again, then the file should not be modified. + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut); + $this->assertNotContains("The following managed scaffold files have been modified:\n - [web-root]/sites/default/default.settings.php", $stdout); + $post_scaffold_contents_three = file_get_contents($sut . '/sites/default/default.settings.php'); + $this->assertNotContains('Simulated user modification', $post_scaffold_contents_three); + + // Modify the test scaffold file even again, then run + // 'composer drupal:scaffold' with DRUPAL_SCAFFOLD_KEEP_MODIFIED. Confirm + // that the file is now excluded in the composer.json file's file-mapping. + $contents = file_get_contents($sut . '/sites/default/default.settings.php'); + file_put_contents($sut . '/sites/default/default.settings.php', $contents . "\n// Simulated user modification\n"); + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut, ['DRUPAL_SCAFFOLD_KEEP_MODIFIED' => 1]); + $this->assertContains("The following managed scaffold files have been modified:\n - [web-root]/sites/default/default.settings.php", $stdout); + $post_scaffold_contents = file_get_contents($sut . '/sites/default/default.settings.php'); + $this->assertContains('Simulated user modification', $post_scaffold_contents); + $post_scaffold_composer_json = file_get_contents($sut . '/composer.json'); + $this->assertContains('"[web-root]/sites/default/default.settings.php": false', $post_scaffold_composer_json); + + // Scaffold again. We should not modify the files we kept + // (default.settings.php) above. + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut, ['DRUPAL_SCAFFOLD_DISCARD_MODIFIED' => 1]); + $this->assertNotContains("The following managed scaffold files have been modified:\n - [web-root]/sites/default/default.settings.php", $stdout); + } + + /** + * Tests deleting and modifying two different scaffold files at the same time. + */ + public function testDeletingAndModifyingAtSameTime() { + $topLevelProjectDir = 'composer-hooks-fixture'; + $sut = $this->fixturesDir . '/' . $topLevelProjectDir; + + // Run 'composer install' again to set up for our next set of tests. + $this->mustExec("composer install --no-ansi", $sut); + + // Delete 'default.settings.php' and modify 'robots.txt', then run + // the scaffold operation and see if the default settings file comes back, + // and the robots.txt file modification is preserved. + @unlink($sut . '/sites/default/default.settings.php'); + $this->assertFileNotExists($sut . '/sites/default/default.settings.php'); + $contents = file_get_contents($sut . '/robots.txt'); + file_put_contents($sut . '/robots.txt', $contents . "\n# Simulated user modification\n"); + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut); + $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + $post_scaffold_contents = file_get_contents($sut . '/robots.txt'); + $this->assertContains('Simulated user modification', $post_scaffold_contents); + + // Run the same test again to ensure that the scaffold tool still + // understands that the deleted file is managed. + @unlink($sut . '/sites/default/default.settings.php'); + $this->assertFileNotExists($sut . '/sites/default/default.settings.php'); + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut); + $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + $post_scaffold_contents = file_get_contents($sut . '/robots.txt'); + $this->assertContains('Simulated user modification', $post_scaffold_contents); + + // Make the same modifications again, but this time we will run the scaffold + // command with 'DRUPAL_SCAFFOLD_DISCARD_MODIFIED' environment variable, + // simulating the user selecting 'y' from the "discard modified" prompt. + // Make sure that both files go back the way they were. + @unlink($sut . '/sites/default/default.settings.php'); + $this->assertFileNotExists($sut . '/sites/default/default.settings.php'); + $contents = file_get_contents($sut . '/robots.txt'); + file_put_contents($sut . '/robots.txt', $contents . "\n# Simulated user modification\n"); + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut, ['DRUPAL_SCAFFOLD_DISCARD_MODIFIED' => 1]); + $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + $post_scaffold_contents = file_get_contents($sut . '/robots.txt'); + $this->assertNotContains('Simulated user modification', $post_scaffold_contents); + + // Now we'll do it again with 'DRUPAL_SCAFFOLD_KEEP_MODIFIED', to simulate + // answering 'keep' to the "discard modified" prompt. Confirm that the + // composer.json file is modified. + @unlink($sut . '/sites/default/default.settings.php'); + $this->assertFileNotExists($sut . '/sites/default/default.settings.php'); + $contents = file_get_contents($sut . '/robots.txt'); + file_put_contents($sut . '/robots.txt', $contents . "\n# Simulated user modification\n"); + $stdout = $this->mustExec("composer drupal:scaffold --no-ansi --no-interaction 2>&1", $sut, ['DRUPAL_SCAFFOLD_KEEP_MODIFIED' => 1]); + $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core'); + $post_scaffold_contents = file_get_contents($sut . '/robots.txt'); + $this->assertContains('Simulated user modification', $post_scaffold_contents); + $post_scaffold_composer_json = file_get_contents($sut . '/composer.json'); + $this->assertContains('"[web-root]/robots.txt": false', $post_scaffold_composer_json); + } + + /** + * Tests to see if create-project scaffolds correctly. + */ + public function testCreateProject() { // Run 'composer create-project' to create a new test project called // 'create-project-test', which is a copy of 'fixtures/drupal-drupal'. $sut = $this->fixturesDir . '/create-project-test'; @@ -127,8 +246,10 @@ public function testComposerHooks() { $this->assertScaffoldedFile($sut . '/index.php', FALSE, 'Test version of index.php from drupal/core'); $topLevelProjectDir = 'composer-hooks-nothing-allowed-fixture'; $sut = $this->fixturesDir . '/' . $topLevelProjectDir; + // Run composer install on an empty project. $this->mustExec("composer install --no-ansi", $sut); + // Require a project that is not allowed to scaffold and confirm that we // get a warning, and it does not scaffold. $stdout = $this->mustExec("composer require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut);