diff --git a/core/composer.json b/core/composer.json
index 4955b07648..118421730f 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -110,6 +110,7 @@
         "drupal/core-proxy-builder": "self.version",
         "drupal/core-render": "self.version",
         "drupal/core-serialization": "self.version",
+        "drupal/core-composer-scaffold": "self.version",
         "drupal/core-transliteration": "self.version",
         "drupal/core-utility": "self.version",
         "drupal/core-uuid": "self.version",
@@ -200,6 +201,7 @@
                 "core/lib/Drupal/Component/ProxyBuilder/composer.json",
                 "core/lib/Drupal/Component/Render/composer.json",
                 "core/lib/Drupal/Component/Serialization/composer.json",
+                "core/lib/Drupal/Component/Scaffold/composer.json",
                 "core/lib/Drupal/Component/Transliteration/composer.json",
                 "core/lib/Drupal/Component/Utility/composer.json",
                 "core/lib/Drupal/Component/Uuid/composer.json",
diff --git a/core/lib/Drupal/Component/Scaffold/AllowedPackages.php b/core/lib/Drupal/Component/Scaffold/AllowedPackages.php
new file mode 100644
index 0000000000..45e858e613
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/AllowedPackages.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\Installer\PackageEvent;
+use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
+
+/**
+ * Determine recursively which packages have been allowed to scaffold files.
+ *
+ * If the root-level composer.json allows drupal/core, and drupal/core allows
+ * drupal/assets, then the later package will also implicitly be allowed.
+ */
+class AllowedPackages implements PostPackageEventListenerInterface {
+
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+
+  /**
+   * Composer's I/O service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+
+  /**
+   * Manager of the options in the top-level composer.json's 'extra' section.
+   *
+   * @var \Drupal\Component\Scaffold\ManageOptions
+   */
+  protected $manageOptions;
+
+  /**
+   * The list of new packages added by this Composer command.
+   *
+   * @var array
+   */
+  protected $newPackages = [];
+
+  /**
+   * AllowedPackages constructor.
+   *
+   * @param \Composer\Composer $composer
+   *   The composer object.
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to write to.
+   * @param \Drupal\Component\Scaffold\ManageOptions $manage_options
+   *   Manager of the options in the top-level composer.json's 'extra' section.
+   */
+  public function __construct(Composer $composer, IOInterface $io, ManageOptions $manage_options) {
+    $this->composer = $composer;
+    $this->io = $io;
+    $this->manageOptions = $manage_options;
+  }
+
+  /**
+   * Gets a list of all packages that are allowed to copy scaffold files.
+   *
+   * Configuration for packages specified later will override configuration
+   * specified by packages listed earlier. In other words, the last listed
+   * package has the highest priority. The root package will always be returned
+   * at the end of the list.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   An array of allowed Composer packages.
+   */
+  public function getAllowedPackages() {
+    $options = $this->manageOptions->getOptions();
+    $allowed_packages = $this->recursiveGetAllowedPackages($options->allowedPackages());
+    // If the root package defines any file mappings, then implicitly add it
+    // to the list of allowed packages. Add it at the end so that it overrides
+    // all the preceding packages.
+    if ($options->hasFileMapping()) {
+      $root_package = $this->composer->getPackage();
+      unset($allowed_packages[$root_package->getName()]);
+      $allowed_packages[$root_package->getName()] = $root_package;
+    }
+    // Handle any newly-added packages that are not already allowed.
+    return $this->evaluateNewPackages($allowed_packages);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function event(PackageEvent $event) {
+    $operation = $event->getOperation();
+    // Determine the package.
+    $package = $operation->getJobType() == 'update' ? $operation->getTargetPackage() : $operation->getPackage();
+    if (ScaffoldOptions::hasOptions($package->getExtra())) {
+      $this->newPackages[$package->getName()] = $package;
+    }
+  }
+
+  /**
+   * Builds a name-to-package mapping from a list of package names.
+   *
+   * @param string[] $packages_to_allow
+   *   List of package names to allow.
+   * @param array $allowed_packages
+   *   Mapping of package names to PackageInterface of packages already
+   *   accumulated.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   Mapping of package names to PackageInterface in priority order.
+   */
+  protected function recursiveGetAllowedPackages(array $packages_to_allow, array $allowed_packages = []) {
+    foreach ($packages_to_allow as $name) {
+      $package = $this->getPackage($name);
+      if ($package && $package instanceof PackageInterface && !array_key_exists($name, $allowed_packages)) {
+        $allowed_packages[$name] = $package;
+        $package_options = $this->manageOptions->packageOptions($package);
+        $allowed_packages = $this->recursiveGetAllowedPackages($package_options->allowedPackages(), $allowed_packages);
+      }
+    }
+    return $allowed_packages;
+  }
+
+  /**
+   * Evaluates newly-added packages and see if they are already allowed.
+   *
+   * For now we will only emit warnings if they are not.
+   *
+   * @param array $allowed_packages
+   *   Mapping of package names to PackageInterface of packages already
+   *   accumulated.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   Mapping of package names to PackageInterface in priority order.
+   */
+  protected function evaluateNewPackages(array $allowed_packages) {
+    foreach ($this->newPackages as $name => $newPackage) {
+      if (!array_key_exists($name, $allowed_packages)) {
+        $this->io->write("Package <comment>{$name}</comment> has scaffold operations, but it is not allowed in the root-level composer.json file.");
+      }
+      else {
+        $this->io->write("Package <comment>{$name}</comment> has scaffold operations, and is already allowed in the root-level composer.json file.");
+      }
+    }
+    // @todo We could prompt the user and ask if they wish to allow a
+    // newly-added package.
+    return $allowed_packages;
+  }
+
+  /**
+   * Retrieves a package from the current composer process.
+   *
+   * @param string $name
+   *   Name of the package to get from the current composer installation.
+   *
+   * @return \Composer\Package\PackageInterface|null
+   *   The Composer package.
+   */
+  protected function getPackage($name) {
+    return $this->composer->getRepositoryManager()->getLocalRepository()->findPackage($name, '*');
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/CommandProvider.php b/core/lib/Drupal/Component/Scaffold/CommandProvider.php
new file mode 100644
index 0000000000..9ec63b4a0d
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/CommandProvider.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
+
+/**
+ * List of all commands provided by this package.
+ */
+class CommandProvider implements CommandProviderCapability {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCommands() {
+    return [new ComposerScaffoldCommand()];
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ComposerScaffoldCommand.php b/core/lib/Drupal/Component/Scaffold/ComposerScaffoldCommand.php
new file mode 100644
index 0000000000..ea93e82c79
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ComposerScaffoldCommand.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Command\BaseCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * The "composer:scaffold" command class.
+ *
+ * Manually run the scaffold operation that normally happens after
+ * 'composer install'.
+ */
+class ComposerScaffoldCommand extends BaseCommand {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function configure() {
+    $this
+      ->setName('composer:scaffold')
+      ->setDescription('Update the Composer scaffold files.')
+      ->setHelp(
+        <<<EOT
+The <info>composer:scaffold</info> command places the scaffold files in their
+respective locations according to the layout stipulated in the composer.json
+file.
+
+<info>php composer.phar composer:scaffold</info>
+
+It is usually not necessary to call <info>composer:scaffold</info> manually,
+because it is called automatically as needed, e.g. after an <info>install</info>
+or <info>update</info> command. Note, though, that only packages explicitly
+allowed to scaffold in the top-level composer.json will be processed by this
+command.
+
+For more information, see https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold.
+EOT
+            );
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(InputInterface $input, OutputInterface $output) {
+    $handler = new Handler($this->getComposer(), $this->getIO());
+    $handler->scaffold();
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php b/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php
new file mode 100644
index 0000000000..d0d715f936
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\IO\IOInterface;
+use Composer\Util\Filesystem;
+use Drupal\Component\Scaffold\Operations\ScaffoldResult;
+
+/**
+ * Generates an 'autoload.php' that includes the autoloader created by Composer.
+ */
+final class GenerateAutoloadReferenceFile {
+
+  /**
+   * This class provides only static methods.
+   */
+  private function __construct() {
+  }
+
+  /**
+   * Generates the autoload file at the specified location.
+   *
+   * This only writes a bit of PHP that includes the autoload file that
+   * Composer generated. Drupal does this so that it can guarantee that there
+   * will always be an `autoload.php` file in a well-known location.
+   *
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to write to.
+   * @param string $package_name
+   *   The name of the package defining the autoload file (the root package).
+   * @param string $web_root
+   *   The path to the web root.
+   * @param string $vendor
+   *   The path to the vendor directory.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+   *   The result of the autoload file generation.
+   */
+  public static function generateAutoload(IOInterface $io, $package_name, $web_root, $vendor) {
+    $autoload_path = static::autoloadPath($package_name, $web_root);
+    $location = dirname($autoload_path->fullPath());
+    // Calculate the relative path from the webroot (location of the project
+    // autoload.php) to the vendor directory.
+    $fs = new Filesystem();
+    $relative_vendor_path = $fs->findShortestPath(realpath($location), $vendor);
+    file_put_contents($autoload_path->fullPath(), static::autoLoadContents($relative_vendor_path));
+    return new ScaffoldResult($autoload_path, TRUE);
+  }
+
+  /**
+   * Determines whether or not the autoload file has been committed.
+   *
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to write to.
+   * @param string $package_name
+   *   The name of the package defining the autoload file (the root package).
+   * @param string $web_root
+   *   The path to the web root.
+   * @return bool
+   *   True if autoload.php file exists and has been committed to the repository
+   */
+  public static function autoloadFileCommitted(IOInterface $io, $package_name, $web_root) {
+    $autoload_path = static::autoloadPath($package_name, $web_root);
+    $location = dirname($autoload_path->fullPath());
+    if (!file_exists($location)) {
+      return false;
+    }
+    return Git::checkTracked($io, $location, $location);
+  }
+
+  /**
+   * Generates a scaffold file path object for the autoload file.
+   *
+   * @param string $package_name
+   *   The name of the package defining the autoload file (the root package).
+   * @param string $web_root
+   *   The path to the web root.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   Object wrapping the relative and absolute path to the destination file.
+   */
+  protected static function autoloadPath($package_name, $web_root) {
+    $rel_path = 'autoload.php';
+    $dest_rel_path = '[web-root]/' . $rel_path;
+    $dest_full_path = $web_root . '/' . $rel_path;
+    return new ScaffoldFilePath('autoload', $package_name, $dest_rel_path, $dest_full_path);
+  }
+
+  /**
+   * Builds the contents of the autoload file.
+   *
+   * @param string $vendor_path
+   *   The relative path to vendor.
+   *
+   * @return string
+   *   Return the contents for the autoload.php.
+   */
+  protected static function autoLoadContents($vendor_path) {
+    $vendor_path = rtrim($vendor_path, '/');
+    return <<<EOF
+<?php
+
+/**
+ * @file
+ * Includes the autoloader created by Composer.
+ *
+ * This file was generated by composer-scaffold.
+ *.
+ * @see composer.json
+ * @see index.php
+ * @see core/install.php
+ * @see core/rebuild.php
+ * @see core/modules/statistics/statistics.php
+ */
+
+return require __DIR__ . '/{$vendor_path}/autoload.php';
+
+EOF;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Git.php b/core/lib/Drupal/Component/Scaffold/Git.php
new file mode 100644
index 0000000000..3c9e0fc073
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Git.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\IO\IOInterface;
+use Composer\Util\ProcessExecutor;
+
+/**
+ * Provide some Git utility operations
+ */
+class Git {
+
+  /**
+   * This class provides only static methods.
+   */
+  private function __construct() {
+  }
+
+  /**
+   * Determines whether the specified scaffold file is already ignored.
+   *
+   * @param string $path
+   *   Path to scaffold file to check.
+   * @param string $dir
+   *   Base directory for git process.
+   *
+   * @return bool
+   *   Whether the specified file is already ignored or not (TRUE if ignored).
+   */
+  public static function checkIgnore(IOInterface $io, $path, $dir = NULL) {
+    $process = new ProcessExecutor($io);
+    $output = '';
+    $exitCode = $process->execute('git check-ignore ' . $process->escape($path), $output, $dir);
+    return $exitCode == 0;
+  }
+
+  /**
+   * Determines whether the specified scaffold file is tracked by git.
+   *
+   * @param string $path
+   *   Path to scaffold file to check.
+   * @param string $dir
+   *   Base directory for git process.
+   *
+   * @return bool
+   *   Whether the specified file is already tracked or not (TRUE if tracked).
+   */
+  public static function checkTracked(IOInterface $io, $path, $dir = NULL) {
+    $process = new ProcessExecutor($io);
+    $output = '';
+    $exitCode = $process->execute('git ls-files --error-unmatch ' . $process->escape($path), $output, $dir);
+    return $exitCode == 0;
+  }
+
+  /**
+   * Checks to see if the project root dir is in a git repository.
+   *
+   * @param string $dir
+   *   Base directory for git process.
+   * @return bool
+   *   True if this is a repository.
+   */
+  public static function isRepository(IOInterface $io, $dir = NULL) {
+    $process = new ProcessExecutor($io);
+    $output = '';
+    $exitCode = $process->execute('git rev-parse --show-toplevel', $output, $dir);
+    return $exitCode == 0;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Handler.php b/core/lib/Drupal/Component/Scaffold/Handler.php
new file mode 100644
index 0000000000..e68af72c1f
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Handler.php
@@ -0,0 +1,241 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\EventDispatcher\EventDispatcher;
+use Composer\Installer\PackageEvent;
+use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
+use Composer\Plugin\CommandEvent;
+use Composer\Util\Filesystem;
+use Drupal\Component\Scaffold\Operations\OperationCollection;
+use Drupal\Component\Scaffold\Operations\OperationData;
+use Drupal\Component\Scaffold\Operations\OperationFactory;
+
+/**
+ * Core class of the plugin.
+ *
+ * Contains the primary logic which determines the files to be fetched and
+ * processed.
+ */
+class Handler {
+
+  /**
+   * Composer hook called before scaffolding begins.
+   */
+  const PRE_COMPOSER_SCAFFOLD_CMD = 'pre-composer-scaffold-cmd';
+
+  /**
+   * Composer hook called after scaffolding completes.
+   */
+  const POST_COMPOSER_SCAFFOLD_CMD = 'post-composer-scaffold-cmd';
+
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+
+  /**
+   * Composer's I/O service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+
+  /**
+   * The scaffold options in the top-level composer.json's 'extra' section.
+   *
+   * @var \Drupal\Component\Scaffold\ManageOptions
+   */
+  protected $manageOptions;
+
+  /**
+   * The manager that keeps track of which packages are allowed to scaffold.
+   *
+   * @var \Drupal\Component\Scaffold\AllowedPackages
+   */
+  protected $manageAllowedPackages;
+
+  /**
+   * The list of listeners that are notified after a package event.
+   *
+   * @var \Drupal\Component\Scaffold\PostPackageEventListenerInterface[]
+   */
+  protected $postPackageListeners = [];
+
+  /**
+   * Handler constructor.
+   *
+   * @param \Composer\Composer $composer
+   *   The Composer service.
+   * @param \Composer\IO\IOInterface $io
+   *   The Composer I/O service.
+   */
+  public function __construct(Composer $composer, IOInterface $io) {
+    $this->composer = $composer;
+    $this->io = $io;
+    $this->manageOptions = new ManageOptions($composer);
+    $this->manageAllowedPackages = new AllowedPackages($composer, $io, $this->manageOptions);
+  }
+
+  /**
+   * Registers post-package events before any 'require' event runs.
+   *
+   * This method is called by composer prior to doing a 'require' command.
+   *
+   * @param \Composer\Plugin\CommandEvent $event
+   *   The Composer Command event.
+   */
+  public function beforeRequire(CommandEvent $event) {
+    // In order to differentiate between post-package events called after
+    // 'composer require' vs. the same events called at other times, we will
+    // only install our handler when a 'require' event is detected.
+    $this->postPackageListeners[] = $this->manageAllowedPackages;
+  }
+
+  /**
+   * Posts package command event.
+   *
+   * We want to detect packages 'require'd that have scaffold files, but are not
+   * yet allowed in the top-level composer.json file.
+   *
+   * @param \Composer\Installer\PackageEvent $event
+   *   Composer package event sent on install/update/remove.
+   */
+  public function onPostPackageEvent(PackageEvent $event) {
+    foreach ($this->postPackageListeners as $listener) {
+      $listener->event($event);
+    }
+  }
+
+  /**
+   * Creates scaffold operation objects for all items in the file mappings.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param array $package_file_mappings
+   *   The package file mappings array keyed by destination path and the values
+   *   are operation metadata arrays.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
+   *   A list of scaffolding operation objects
+   */
+  protected function createScaffoldOperations(PackageInterface $package, array $package_file_mappings) {
+    $scaffold_op_factory = new OperationFactory($this->composer);
+    $scaffold_ops = [];
+    foreach ($package_file_mappings as $dest_rel_path => $data) {
+      $operation_data = new OperationData($dest_rel_path, $data);
+      $scaffold_ops[$dest_rel_path] = $scaffold_op_factory->create($package, $operation_data);
+    }
+    return $scaffold_ops;
+  }
+
+  /**
+   * Copies all scaffold files from source to destination.
+   */
+  public function scaffold() {
+    // Recursively get the list of allowed packages. Only allowed packages
+    // may declare scaffold files. Note that the top-level composer.json file
+    // is implicitly allowed.
+    $allowed_packages = $this->manageAllowedPackages->getAllowedPackages();
+    if (empty($allowed_packages)) {
+      $this->io->write("Nothing scaffolded because no packages are allowed in the top-level composer.json file.");
+      return;
+    }
+
+    // Call any pre-scaffold scripts that may be defined.
+    $dispatcher = new EventDispatcher($this->composer, $this->io);
+    $dispatcher->dispatch(self::PRE_COMPOSER_SCAFFOLD_CMD);
+
+    // Fetch the list of file mappings from each allowed package and normalize
+    // them.
+    $file_mappings = $this->getFileMappingsFromPackages($allowed_packages);
+
+    // Analyze the list of file mappings, and determine which take priority.
+    $scaffold_collection = new OperationCollection($this->io);
+    $location_replacements = $this->manageOptions->getLocationReplacements();
+
+    // Write the collected scaffold files to the designated location on disk.
+    $scaffold_results = $scaffold_collection->process($file_mappings, $location_replacements, $this->manageOptions->getOptions());
+
+    // Generate an autoload file in the document root that includes the
+    // autoload.php file in the vendor directory, wherever that is. Drupal
+    // requires this in order to easily locate relocated vendor dirs.
+    $web_root = $this->manageOptions->getOptions()->getLocation('web-root');
+    if (!GenerateAutoloadReferenceFile::autoloadFileCommitted($this->io, $this->rootPackageName(), $web_root)) {
+      $scaffold_results[] = GenerateAutoloadReferenceFile::generateAutoload($this->io, $this->rootPackageName(), $web_root, $this->getVendorPath());
+    }
+
+    // Add the managed scaffold files to .gitignore if applicable.
+    $gitIgnoreManager = new ManageGitIgnore($this->io, getcwd());
+    $gitIgnoreManager->manageIgnored($scaffold_results, $this->manageOptions->getOptions());
+
+    // Call post-scaffold scripts.
+    $dispatcher->dispatch(self::POST_COMPOSER_SCAFFOLD_CMD);
+  }
+
+  /**
+   * Gets the path to the 'vendor' directory.
+   *
+   * @return string
+   *   The file path of the vendor directory.
+   */
+  protected function getVendorPath() {
+    $vendor_dir = $this->composer->getConfig()->get('vendor-dir');
+    $filesystem = new Filesystem();
+    return $filesystem->normalizePath(realpath($vendor_dir));
+  }
+
+  /**
+   * Gets a consolidated list of file mappings from all allowed packages.
+   *
+   * @param \Composer\Package\Package[] $allowed_packages
+   *   A multidimensional array of file mappings, as returned by
+   *   self::getAllowedPackages().
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
+   *   An array of destination paths => scaffold operation objects.
+   */
+  protected function getFileMappingsFromPackages(array $allowed_packages) {
+    $file_mappings = [];
+    foreach ($allowed_packages as $package_name => $package) {
+      $file_mappings[$package_name] = $this->getPackageFileMappings($package);
+    }
+    return $file_mappings;
+  }
+
+  /**
+   * Gets the array of file mappings provided by a given package.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The Composer package from which to get the file mappings.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
+   *   An array of destination paths => scaffold operation objects.
+   */
+  protected function getPackageFileMappings(PackageInterface $package) {
+    $options = $this->manageOptions->packageOptions($package);
+    if ($options->hasFileMapping()) {
+      return $this->createScaffoldOperations($package, $options->fileMapping());
+    }
+    if (!$options->hasAllowedPackages()) {
+      $this->io->writeError("The allowed package {$package->getName()} does not provide a file mapping for Composer Scaffold.");
+    }
+    return [];
+  }
+
+  /**
+   * Gets the root package name.
+   *
+   * @return string
+   *   The package name of the root project
+   */
+  protected function rootPackageName() {
+    $root_package = $this->composer->getPackage();
+    return $root_package->getName();
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Interpolator.php b/core/lib/Drupal/Component/Scaffold/Interpolator.php
new file mode 100644
index 0000000000..860a1a3305
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Interpolator.php
@@ -0,0 +1,155 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+/**
+ * Injects config values from an associative array into a string.
+ */
+class Interpolator {
+
+  /**
+   * The character sequence that identifies the start of a token.
+   *
+   * @var string
+   */
+  protected $startToken;
+
+  /**
+   * The character sequence that identifies the end of a token.
+   *
+   * @var string
+   */
+  protected $endToken;
+
+  /**
+   * The associative array of replacements.
+   *
+   * @var array
+   */
+  protected $data = [];
+
+  /**
+   * Interpolator constructor.
+   *
+   * @param string $start_token
+   *   The start marker for a token, e.g. '['.
+   * @param string $end_token
+   *   The end marker for a token, e.g. ']'.
+   */
+  public function __construct($start_token = '\\[', $end_token = '\\]') {
+    $this->startToken = $start_token;
+    $this->endToken = $end_token;
+  }
+
+  /**
+   * Sets the data set to use when interpolating.
+   *
+   * @param array $data
+   *   key:value pairs to use when interpolating.
+   *
+   * @return $this
+   */
+  public function setData(array $data) {
+    $this->data = $data;
+    return $this;
+  }
+
+  /**
+   * Adds to the data set to use when interpolating.
+   *
+   * @param array $data
+   *   key:value pairs to use when interpolating.
+   *
+   * @return $this
+   */
+  public function addData(array $data) {
+    $this->data = array_merge($this->data, $data);
+    return $this;
+  }
+
+  /**
+   * Replaces tokens in a string with values from an associative array.
+   *
+   * Tokens are surrounded by delimiters, e.g. square brackets "[key]". The
+   * characters that surround the key may be defined when the Interpolator is
+   * constructed.
+   *
+   * Example:
+   * If the message is 'Hello, [user.name]', then the value of the user.name
+   * item is fetched from the array, and the token [user.name] is replaced with
+   * the result.
+   *
+   * @param string $message
+   *   Message containing tokens to be replaced.
+   * @param array $extra
+   *   Data to use for interpolation in addition to whatever was provided to
+   *   self::setData().
+   * @param string|bool $default
+   *   (optional) The value to substitute for tokens that are not found in the
+   *   data. If FALSE, then missing tokens are not replaced. Defaults to an
+   *   empty string.
+   *
+   * @return string
+   *   The message after replacements have been made.
+   */
+  public function interpolate($message, array $extra = [], $default = '') {
+    $data = $extra + $this->data;
+    $replacements = $this->replacements($message, $data, $default);
+    return strtr($message, $replacements);
+  }
+
+  /**
+   * Finds the tokens that exist in a message and builds a replacement array.
+   *
+   * All of the replacements in the data array are looked up given the token
+   * keys from the provided message. Keys that do not exist in the configuration
+   * are replaced with the default value.
+   *
+   * @param string $message
+   *   String with tokens.
+   * @param array $data
+   *   Data to use for interpolation.
+   * @param string $default
+   *   (optional) The value to substitute for tokens that are not found in the
+   *   data. If FALSE, then missing tokens are not replaced. Defaults to an
+   *   empty string.
+   *
+   * @return string[]
+   *   An array of replacements to make. Keyed by tokens and the replacements
+   *   are the values.
+   */
+  protected function replacements($message, array $data, $default = '') {
+    $tokens = $this->findTokens($message);
+    $replacements = [];
+    foreach ($tokens as $sourceText => $key) {
+      $replacement_text = array_key_exists($key, $data) ? $data[$key] : $default;
+      if ($replacement_text !== FALSE) {
+        $replacements[$sourceText] = $replacement_text;
+      }
+    }
+    return $replacements;
+  }
+
+  /**
+   * Finds all of the tokens in the provided message.
+   *
+   * @param string $message
+   *   String with tokens.
+   *
+   * @return string[]
+   *   map of token to key, e.g. {{key}} => key
+   */
+  protected function findTokens($message) {
+    $reg_ex = '#' . $this->startToken . '([a-zA-Z0-9._-]+)' . $this->endToken . '#';
+    if (!preg_match_all($reg_ex, $message, $matches, PREG_SET_ORDER)) {
+      return [];
+    }
+    $tokens = [];
+    foreach ($matches as $matchSet) {
+      list($sourceText, $key) = $matchSet;
+      $tokens[$sourceText] = $key;
+    }
+    return $tokens;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/LICENSE.txt b/core/lib/Drupal/Component/Scaffold/LICENSE.txt
new file mode 100644
index 0000000000..94fb84639c
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/LICENSE.txt
@@ -0,0 +1,339 @@
+        GNU GENERAL PUBLIC LICENSE
+           Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+          Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+        GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+          NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+         END OF TERMS AND CONDITIONS
+
+      How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php b/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php
new file mode 100644
index 0000000000..d0714c9bd1
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\IO\IOInterface;
+
+/**
+ * Manage the .gitignore file.
+ */
+class ManageGitIgnore {
+
+  /**
+   * Composer's I/O service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+
+  /**
+   * The directory where the project is located.
+   *
+   * @var string
+   */
+  protected $dir;
+
+  /**
+   * ManageGitIgnore constructor.
+   *
+   * @param string $dir
+   *   The directory where the project is located.
+   */
+  public function __construct(IOInterface $io, $dir) {
+    $this->io = $io;
+    $this->dir = $dir;
+  }
+
+  /**
+   * Manages gitignore files.
+   *
+   * @param \Drupal\Component\Scaffold\Operations\ScaffoldResult[] $files
+   *   A list of scaffold results, each of which holds a path and whether
+   *   or not that file is managed.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the composer.json extras section.
+   */
+  public function manageIgnored(array $files, ScaffoldOptions $options) {
+    if (!$this->managementOfGitIgnoreEnabled($options)) {
+      return;
+    }
+
+    // Accumulate entries to add to .gitignore, sorted into buckets based on the
+    // location of the .gitignore file the entry should be added to.
+    $add_to_git_ignore = [];
+    foreach ($files as $scaffoldResult) {
+      $path = $scaffoldResult->destination()->fullPath();
+      $is_ignored = Git::checkIgnore($this->io, $path, $this->dir);
+      if (!$is_ignored) {
+        $is_tracked = Git::checkTracked($this->io, $path, $this->dir);
+        if (!$is_tracked && $scaffoldResult->isManaged()) {
+          $dir = realpath(dirname($path));
+          $name = basename($path);
+          $add_to_git_ignore[$dir][] = $name;
+        }
+      }
+    }
+    // Write out the .gitignore files one at a time.
+    foreach ($add_to_git_ignore as $dir => $entries) {
+      $this->addToGitIgnore($dir, $entries);
+    }
+  }
+
+  /**
+   * Determines whether we should manage gitignore files.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the composer.json extras section.
+   *
+   * @return bool
+   *   Whether or not gitignore files should be managed.
+   */
+  protected function managementOfGitIgnoreEnabled(ScaffoldOptions $options) {
+    // If the composer.json stipulates whether gitignore is managed or not, then
+    // follow its recommendation.
+    if ($options->hasGitIgnore()) {
+      return $options->gitIgnore();
+    }
+
+    // Do not manage .gitignore if there is no repository here.
+    if (!Git::isRepository($this->io, $this->dir)) {
+      return FALSE;
+    }
+
+    // If the composer.json did not specify whether or not .gitignore files
+    // should be managed, then manage them if the vendor directory is not
+    // committed.
+    return !Git::checkTracked($this->io, 'vendor', $this->dir);
+  }
+
+  /**
+   * Adds a set of entries to the specified .gitignore file.
+   *
+   * @param string $dir
+   *   Path to directory where gitignore should be written.
+   * @param string[] $entries
+   *   Entries to write to .gitignore file.
+   */
+  protected function addToGitIgnore($dir, array $entries) {
+    sort($entries);
+    $git_ignore_path = $dir . '/.gitignore';
+    $contents = '';
+
+    // Appending to existing .gitignore files.
+    if (file_exists($git_ignore_path)) {
+      $contents = file_get_contents($git_ignore_path);
+      if (!empty($contents) && substr($contents, -1) != "\n") {
+        $contents .= "\n";
+      }
+    }
+
+    $contents .= implode("\n", $entries);
+    file_put_contents($git_ignore_path, $contents);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ManageOptions.php b/core/lib/Drupal/Component/Scaffold/ManageOptions.php
new file mode 100644
index 0000000000..483de0e72a
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ManageOptions.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\Package\PackageInterface;
+use Composer\Util\Filesystem;
+
+/**
+ * Per-project options from the 'extras' section of the composer.json file.
+ *
+ * Projects that describe scaffold files do so via their scaffold options.
+ * This data is pulled from the 'composer-scaffold' portion of the extras
+ * section of the project data.
+ */
+class ManageOptions {
+
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+
+  /**
+   * ManageOptions constructor.
+   *
+   * @param \Composer\Composer $composer
+   *   The Composer service.
+   */
+  public function __construct(Composer $composer) {
+    $this->composer = $composer;
+  }
+
+  /**
+   * Gets the root-level scaffold options for this project.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldOptions
+   *   The scaffold options object.
+   */
+  public function getOptions() {
+    return $this->packageOptions($this->composer->getPackage());
+  }
+
+  /**
+   * Gets the scaffold options for the stipulated project.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package to fetch the scaffold options from.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldOptions
+   *   The scaffold options object.
+   */
+  public function packageOptions(PackageInterface $package) {
+    return ScaffoldOptions::create($package->getExtra());
+  }
+
+  /**
+   * Creates an interpolator for the 'locations' element.
+   *
+   * The interpolator returned will replace a path string with the tokens
+   * defined in the 'locations' element.
+   *
+   * Note that only the root package may define locations.
+   *
+   * @return \Drupal\Component\Scaffold\Interpolator
+   *   Interpolator that will do replacements in a string using tokens in
+   *   'locations' element.
+   */
+  public function getLocationReplacements() {
+    return (new Interpolator())->setData($this->ensureLocations());
+  }
+
+  /**
+   * Ensures that all of the locations defined in the scaffold files exist.
+   *
+   * Create them on the filesystem if they do not.
+   */
+  protected function ensureLocations() {
+    $fs = new Filesystem();
+    $locations = $this->getOptions()->locations() + ['web_root' => './'];
+    $locations = array_map(function ($location) use ($fs) {
+      $fs->ensureDirectoryExists($location);
+      $location = realpath($location);
+      return $location;
+    }, $locations);
+    return $locations;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php b/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php
new file mode 100644
index 0000000000..a608180abc
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Scaffold operation to add to the beginning and/or end of a scaffold file.
+ */
+class AppendOp implements OperationInterface, ConjoinableInterface {
+
+  /**
+   * Identifies Append operations.
+   */
+  const ID = 'append';
+
+  /**
+   * Path to the source file to prepend, if any.
+   *
+   * @var \Drupal\Component\Scaffold\ScaffoldFilePath
+   */
+  protected $prepend;
+
+  /**
+   * Path to the source file to append, if any.
+   *
+   * @var \Drupal\Component\Scaffold\ScaffoldFilePath
+   */
+  protected $append;
+
+  /**
+   * Constructs an AppendOp.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $prepend_path
+   *   The relative path to the prepend file.
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $append_path
+   *   The relative path to the append file.
+   */
+  public function __construct(ScaffoldFilePath $prepend_path = NULL, ScaffoldFilePath $append_path = NULL) {
+    $this->prepend = $prepend_path;
+    $this->append = $append_path;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $destination_path = $destination->fullPath();
+    if (!file_exists($destination_path)) {
+      throw new \RuntimeException($destination->getInterpolator()->interpolate("Cannot append/prepend because no prior package provided a scaffold file at that [dest-rel-path]."));
+    }
+    $interpolator = $destination->getInterpolator();
+
+    // Fetch the prepend contents, if provided.
+    $prepend_contents = '';
+    if (!empty($this->prepend)) {
+      $this->prepend->addInterpolationData($interpolator, 'prepend');
+      $prepend_contents = file_get_contents($this->prepend->fullPath()) . "\n";
+      $io->write($interpolator->interpolate("  - Prepend to <info>[dest-rel-path]</info> from <info>[prepend-rel-path]</info>"));
+    }
+    // Fetch the append contents, if provided.
+    $append_contents = '';
+    if (!empty($this->append)) {
+      $this->append->addInterpolationData($interpolator, 'append');
+      $append_contents = "\n" . file_get_contents($this->append->fullPath());
+      $io->write($interpolator->interpolate("  - Append to <info>[dest-rel-path]</info> from <info>[append-rel-path]</info>"));
+    }
+    if (!empty(trim($prepend_contents)) || !empty(trim($append_contents))) {
+      // Assume that none of these files is very large, so load them all into
+      // memory for now. Considering uses streams to scaffold large files.
+      $original_contents = file_get_contents($destination_path);
+      // Write the appended and prepended contents back to the file.
+      $altered_contents = $prepend_contents . $original_contents . $append_contents;
+      file_put_contents($destination_path, $altered_contents);
+    }
+    else {
+      $io->write($interpolator->interpolate("  - Keep <info>[dest-rel-path]</info> unchanged: no content to prepend / append was provided."));
+    }
+    return new ScaffoldResult($destination, TRUE);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ConjoinableInterface.php b/core/lib/Drupal/Component/Scaffold/Operations/ConjoinableInterface.php
new file mode 100644
index 0000000000..e7528d24a8
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ConjoinableInterface.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+/**
+ * Marker interface indicating that operation is conjoinable.
+ *
+ * A conjoinable operation is one that runs in addition to any previous
+ * operation defined at the same destination path. Operations that are
+ * not conjoinable simply replace anything at the same destination path.
+ */
+interface ConjoinableInterface {
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ConjunctionOp.php b/core/lib/Drupal/Component/Scaffold/Operations/ConjunctionOp.php
new file mode 100644
index 0000000000..a7b035ef98
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ConjunctionOp.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Joins two operations on the same file into a single operation.
+ */
+class ConjunctionOp implements OperationInterface {
+
+  /**
+   * The first operation.
+   *
+   * @var \Drupal\Component\Scaffold\Operations\OperationInterface
+   */
+  protected $firstOperation;
+
+  /**
+   * The second operation.
+   *
+   * @var \Drupal\Component\Scaffold\Operations\OperationInterface
+   */
+  protected $secondOperation;
+
+  /**
+   * ConjunctionOp constructor.
+   *
+   * @param \Drupal\Component\Scaffold\Operations\OperationInterface $first_operation
+   * @param \Drupal\Component\Scaffold\Operations\OperationInterface $second_operation
+   */
+  public function __construct(OperationInterface $first_operation, OperationInterface $second_operation) {
+    $this->firstOperation = $first_operation;
+    $this->secondOperation = $second_operation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $destination_path = $destination->fullPath();
+    // First, scaffold the original file. Disable symlinking, because we
+    // need a copy of the file if we're going to append / prepend to it.
+    @unlink($destination_path);
+    $this->firstOperation->process($destination, $io, $options->overrideSymlink(FALSE));
+    return $this->secondOperation->process($destination, $io, $options);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php
new file mode 100644
index 0000000000..ad0b734e64
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\Interpolator;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Manages and processes the collection of files to be scaffolded.
+ */
+class OperationCollection {
+
+  /**
+   * Composer's I/O service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+
+  /**
+   * OperationCollection constructor.
+   *
+   * @param \Composer\IO\IOInterface $io
+   *   A reference to the IO object, to allow us to write progress messages
+   *   as we process scaffold operations.
+   */
+  public function __construct(IOInterface $io) {
+    $this->io = $io;
+  }
+
+  /**
+   * Process all of the scaffold files listed in the provided file mappings.
+   *
+   * @param array $file_mappings
+   *   An multidimensional array of file mappings, as returned by
+   *   self::getFileMappingsFromPackages().
+   * @param \Drupal\Component\Scaffold\Interpolator $location_replacements
+   *   An object with the location mappings (e.g. [web-root]).
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the top-level composer.json file.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult[]
+   *   Associative array keyed by destination path and values as the scaffold
+   *   result for each scaffolded file.
+   */
+  public function process(array $file_mappings, Interpolator $location_replacements, ScaffoldOptions $options) {
+    $collatedScaffoldFiles = new ScaffoldFileCollator($this->io);
+    $collatedScaffoldFiles->collateScaffoldFiles($file_mappings, $location_replacements);
+    return $this->processScaffoldFiles($collatedScaffoldFiles, $options);
+  }
+
+  /**
+   * Scaffolds the files in our scaffold collection, package-by-package.
+   *
+   * @param ScaffoldFileCollator $collatedScaffoldFiles
+   *   Collection of collated scaffold files.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the top-level composer.json file.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult[]
+   *   Associative array keyed by destination path and values as the scaffold
+   *   result for each scaffolded file.
+   */
+  protected function processScaffoldFiles(ScaffoldFileCollator $collatedScaffoldFiles, ScaffoldOptions $options) {
+    $result = [];
+    // Process all of the files that were collated one package at a time.
+    foreach ($collatedScaffoldFiles->resolvedScaffoldFiles() as $package_name => $package_scaffold_files) {
+      $this->io->write("Scaffolding files for <comment>{$package_name}</comment>:");
+      foreach ($package_scaffold_files as $dest_rel_path => $scaffold_file) {
+        if (!$collatedScaffoldFiles->overridden($scaffold_file)) {
+          $result[$dest_rel_path] = $scaffold_file->process($this->io, $options);
+        }
+      }
+    }
+    return $result;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationData.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationData.php
new file mode 100644
index 0000000000..c400faa61f
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationData.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+/**
+ * Holds parameter data for operation objects during operation creation only.
+ */
+class OperationData {
+
+  const MODE = 'mode';
+  const PATH = 'path';
+  const OVERWRITE = 'overwrite';
+  const PREPEND = 'prepend';
+  const APPEND = 'append';
+
+  /**
+   * The parameter data.
+   *
+   * @var array
+   */
+  protected $data;
+
+  /**
+   * The destination path
+   *
+   * @var string
+   */
+  protected $destination;
+
+  /**
+   * OperationData constructor.
+   *
+   * @param mixed $data
+   *   The raw data array to wrap.
+   */
+  public function __construct($destination, $data) {
+    $this->destination = $destination;
+    $this->data = $this->normalizeScaffoldMetadata($destination, $data);
+  }
+
+  /**
+   * Gets the destination path that this operation data is associated with.
+   *
+   * @return string
+   *   The destination path for the scaffold result.
+   */
+  public function destination() {
+    return $this->destination;
+  }
+
+  /**
+   * Gets operation mode
+   *
+   * @return string
+   *   Operation mode.
+   */
+  public function mode() {
+    return $this->data[self::MODE];
+  }
+
+  /**
+   * Checks if path exists
+   *
+   * @return bool
+   *   Returns true if path exists
+   */
+  public function hasPath() {
+    return isset($this->data[self::PATH]);
+  }
+
+  /**
+   * Gets path
+   *
+   * @return string
+   *   The path.
+   */
+  public function path() {
+    return $this->data[self::PATH];
+  }
+
+  /**
+   * Determines overwrite.
+   *
+   * @return bool
+   *   Returns true if overwrite mode was selected.
+   */
+  public function overwrite() {
+    return isset($this->data[self::OVERWRITE]) ? $this->data[self::OVERWRITE] : TRUE;
+  }
+
+  /**
+   * Checks if prepend path exists.
+   *
+   * @return bool
+   *   Returns true if prepend exists.
+   */
+  public function hasPrepend() {
+    return isset($this->data[self::PREPEND]);
+  }
+
+  /**
+   * Gets prepend path.
+   *
+   * @return string
+   *   Path to prepend data
+   */
+  public function prepend() {
+    return $this->data[self::PREPEND];
+  }
+
+  /**
+   * Checks if append path exists.
+   *
+   * @return bool
+   *   Returns true if prepend exists.
+   */
+  public function hasAppend() {
+    return isset($this->data[self::APPEND]);
+  }
+
+  /**
+   * Gets append path.
+   *
+   * @return string
+   *   Path to append data
+   */
+  public function append() {
+    return $this->data[self::APPEND];
+  }
+
+  /**
+   * Normalizes metadata by converting literal values into arrays.
+   *
+   * Conversions performed include:
+   *   - Boolean 'false' means "skip".
+   *   - A string means "replace", with the string value becoming the path.
+   *
+   * @param string $destination
+   *   The destination path for the scaffold file.
+   * @param mixed $value
+   *   The metadata for this operation object, which varies by operation type.
+   *
+   * @return array
+   *   Normalized scaffold metadata.
+   */
+  protected function normalizeScaffoldMetadata($destination, $value) {
+    if (is_bool($value)) {
+      if (!$value) {
+        return [self::MODE => SkipOp::ID];
+      }
+      throw new \RuntimeException("File mapping {$destination} cannot be given the value 'true'.");
+    }
+    if (empty($value)) {
+      throw new \RuntimeException("File mapping {$destination} cannot be empty.");
+    }
+    if (is_string($value)) {
+      $value = [self::PATH => $value];
+    }
+    // If there is no 'mode', but there is an 'append' or a 'prepend' path,
+    // then the mode is 'append' (append + prepend).
+    if (!isset($value[self::MODE]) && (isset($value[self::APPEND]) || isset($value[self::PREPEND]))) {
+      $value[self::MODE] = AppendOp::ID;
+    }
+    // If there is no 'mode', then the default is 'replace'.
+    if (!isset($value[self::MODE])) {
+      $value[self::MODE] = ReplaceOp::ID;
+    }
+    return $value;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php
new file mode 100644
index 0000000000..7884390908
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\Composer;
+use Composer\Package\PackageInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+
+/**
+ * Create Scaffold operation objects based on provided metadata.
+ */
+class OperationFactory {
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+
+  /**
+   * OperationFactory constructor.
+   *
+   * @param \Composer\Composer $composer
+   *   Reference to the 'Composer' object, since the Scaffold Operation Factory
+   *   is also responsible for evaluating relative package paths as it creates
+   *   scaffold operations.
+   */
+  public function __construct(Composer $composer) {
+    $this->composer = $composer;
+  }
+
+  /**
+   * Creates a scaffolding operation object as determined by the metadata.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param OperationData $operation_data
+   *   The parameter data for this operation object; varies by operation type.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   The scaffolding operation object (skip, replace, etc.)
+   *
+   * @throws \RuntimeException
+   *   Exception thrown when parameter data does not identify a known scaffol
+   *   operation.
+   */
+  public function create(PackageInterface $package, OperationData $operation_data) {
+    switch ($operation_data->mode()) {
+      case SkipOp::ID:
+        return new SkipOp();
+
+      case 'replace':
+        return $this->createReplaceOp($package, $operation_data);
+
+      case AppendOp::ID:
+        return $this->createAppendOp($package, $operation_data);
+    }
+    throw new \RuntimeException("Unknown scaffold operation mode <comment>{$operation_data->mode()}</comment>.");
+  }
+
+  /**
+   * Creates a 'replace' scaffold op.
+   *
+   * Replace ops may copy or symlink, depending on settings.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param OperationData $operation_data
+   *   The parameter data for this operation object, i.e. the relative 'path'.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   A scaffold replace operation object.
+   */
+  protected function createReplaceOp(PackageInterface $package, OperationData $operation_data) {
+    if (!$operation_data->hasPath()) {
+      throw new \RuntimeException("'path' component required for 'replace' operations.");
+    }
+    $package_name = $package->getName();
+    $package_path = $this->getPackagePath($package);
+    $source = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->path());
+    $op = new ReplaceOp($source, $operation_data->overwrite());
+    return $op;
+  }
+
+  /**
+   * Creates an 'append' (or 'prepend') scaffold op.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param OperationData $operation_data
+   *   The parameter data for this operation object, i.e. the relative 'path'.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   A scaffold replace operation object.
+   */
+  protected function createAppendOp(PackageInterface $package, OperationData $operation_data) {
+    $package_name = $package->getName();
+    $package_path = $this->getPackagePath($package);
+    $prepend_source_file = NULL;
+    $append_source_file = NULL;
+    if ($operation_data->hasPrepend()) {
+      $prepend_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->prepend());
+    }
+    if ($operation_data->hasAppend()) {
+      $append_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->append());
+    }
+    $op = new AppendOp($prepend_source_file, $append_source_file);
+    return $op;
+  }
+
+  /**
+   * Gets the file path of a package.
+   *
+   * Note that if we call getInstallPath on the root package, we get the
+   * wrong answer (the installation manager thinks our package is in
+   * vendor). We therefore add special checking for this case.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package.
+   *
+   * @return string
+   *   The file path.
+   */
+  protected function getPackagePath(PackageInterface $package) {
+    if ($package->getName() == $this->composer->getPackage()->getName()) {
+      // This will respect the --working-dir option if Composer is invoked with
+      // it. There is no API or method to determine the filesystem path of
+      // a package's composer.json file.
+      return getcwd();
+    }
+    return $this->composer->getInstallationManager()->getInstallPath($package);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php
new file mode 100644
index 0000000000..8fa0cee2be
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Interface for scaffold operation objects.
+ */
+interface OperationInterface {
+
+  /**
+   * Process this scaffold operation.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   Scaffold file's destination path.
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to write to.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Various options that may alter the behavior of the operation.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+   *   Result of the scaffolding operation.
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options);
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ReplaceOp.php b/core/lib/Drupal/Component/Scaffold/Operations/ReplaceOp.php
new file mode 100644
index 0000000000..7250a49b2e
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ReplaceOp.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Composer\Util\Filesystem;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Scaffold operation to copy or symlink from source to destination.
+ */
+class ReplaceOp implements OperationInterface {
+
+  /**
+   * Identifies Replace operations.
+   */
+  const ID = 'replace';
+
+  /**
+   * The relative path to the source file.
+   *
+   * @var \Drupal\Component\Scaffold\ScaffoldFilePath
+   */
+  protected $source;
+
+  /**
+   * Whether to overwrite existing files.
+   *
+   * @var bool
+   */
+  protected $overwrite;
+
+  /**
+   * Constructs a ReplaceOp.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $sourcePath
+   *   The relative path to the source file.
+   * @param bool $overwrite
+   *   Whether to allow this scaffold file to overwrite files already at
+   *   the destination. Defaults to TRUE.
+   */
+  public function __construct(ScaffoldFilePath $sourcePath, $overwrite = TRUE) {
+    $this->source = $sourcePath;
+    $this->overwrite = $overwrite;
+  }
+
+  /**
+   * Copy or Symlink the specified scaffold file.
+   *
+   * {@inheritdoc}
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $fs = new Filesystem();
+    $destination_path = $destination->fullPath();
+    // Do nothing if overwrite is 'false' and a file already exists at the
+    // destination.
+    if ($this->overwrite === FALSE && file_exists($destination_path)) {
+      $interpolator = $destination->getInterpolator();
+      $io->write($interpolator->interpolate("  - Skip <info>[dest-rel-path]</info> because it already exists and overwrite is <comment>false</comment>."));
+      return new ScaffoldResult($destination, FALSE);
+    }
+
+    // Get rid of the destination if it exists, and make sure that
+    // the directory where it's going to be placed exists.
+    $fs->remove($destination_path);
+    $fs->ensureDirectoryExists(dirname($destination_path));
+    if ($options->symlink()) {
+      return $this->symlinkScaffold($destination, $io);
+    }
+    return $this->copyScaffold($destination, $io);
+  }
+
+  /**
+   * Copies the scaffold file.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   Scaffold file to process.
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to writing to.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+   *   The scaffold result.
+   */
+  protected function copyScaffold(ScaffoldFilePath $destination, IOInterface $io) {
+    $interpolator = $destination->getInterpolator();
+    $this->source->addInterpolationData($interpolator);
+    $fs = new Filesystem();
+    $success = $fs->copy($this->source->fullPath(), $destination->fullPath());
+    if (!$success) {
+      throw new \RuntimeException($interpolator->interpolate("Could not copy source file <info>[src-rel-path]</info> to <info>[dest-rel-path]</info>!"));
+    }
+    $io->write($interpolator->interpolate("  - Copy <info>[dest-rel-path]</info> from <info>[src-rel-path]</info>"));
+    return new ScaffoldResult($destination, $this->overwrite);
+  }
+
+  /**
+   * Symlinks the scaffold file.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   Scaffold file to process.
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to writing to.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+   *   The scaffold result.
+   */
+  protected function symlinkScaffold(ScaffoldFilePath $destination, IOInterface $io) {
+    $interpolator = $destination->getInterpolator();
+    try {
+      $fs = new Filesystem();
+      $fs->relativeSymlink($this->source->fullPath(), $destination->fullPath());
+    }
+    catch (\Exception $e) {
+      throw new \RuntimeException($interpolator->interpolate("Could not symlink source file <info>[src-rel-path]</info> to <info>[dest-rel-path]</info>!"), [], $e);
+    }
+    $io->write($interpolator->interpolate("  - Link <info>[dest-rel-path]</info> from <info>[src-rel-path]</info>"));
+    return new ScaffoldResult($destination, $this->overwrite);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldFileCollator.php b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldFileCollator.php
new file mode 100644
index 0000000000..6c77b9c68d
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldFileCollator.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\Interpolator;
+use Drupal\Component\Scaffold\ScaffoldFileInfo;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+
+/**
+ * Collates the scaffold file for the OperationCollection class.
+ */
+class ScaffoldFileCollator {
+
+  /**
+   * Composer's I/O service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+
+  /**
+   * Collation of all scaffold files. The top level array maps from the
+   * package name to the collection of scaffold files provided by that
+   * package. Each collection of scaffold files is keyed by destination path.
+   *
+   * @var \Drupal\Component\Scaffold\ScaffoldFileInfo[][]
+   */
+  protected $resolvedFileMappings = [];
+
+  /**
+   * Collection of all destination paths to be scaffolded to the last file
+   * to scaffold at each location.
+   *
+   * @var \Drupal\Component\Scaffold\ScaffoldFileInfo[]
+   */
+  protected $listOfScaffoldFiles = [];
+
+  /**
+   * OperationCollection constructor.
+   *
+   * @param \Composer\IO\IOInterface $io
+   *   A reference to the IO object, to allow us to write progress messages
+   *   as we process scaffold operations.
+   */
+  public function __construct(IOInterface $io) {
+    $this->io = $io;
+  }
+
+  /**
+   * Organizes provided file mappings by destination and package.
+   *
+   * @param array $file_mappings
+   *   An multidimensional array of file mappings, as returned by
+   *   self::getFileMappingsFromPackages().
+   * @param \Drupal\Component\Scaffold\Interpolator $location_replacements
+   *   An object with the location mappings (e.g. [web-root]).
+   */
+  public function collateScaffoldFiles(array $file_mappings, Interpolator $location_replacements) {
+    foreach ($file_mappings as $package_name => $package_file_mappings) {
+      foreach ($package_file_mappings as $destination_rel_path => $op) {
+        $destination = ScaffoldFilePath::destinationPath($package_name, $destination_rel_path, $location_replacements);
+        // If there was already a scaffolding operation happening at this path,
+        // and the new operation is Conjoinable, then use a ConjunctionOp to
+        // join together both operations. This will cause both operations to
+        // run, one after the other. At the moment, only AppendOp is
+        // conjoinable; all other operations simply replace anything at the same
+        // path.
+        if (isset($this->listOfScaffoldFiles[$destination_rel_path]) && $op instanceof ConjoinableInterface) {
+          $op = new ConjunctionOp($this->listOfScaffoldFiles[$destination_rel_path]->op(), $op);
+        }
+
+        $scaffold_file = new ScaffoldFileInfo($destination, $op);
+        $this->listOfScaffoldFiles[$destination_rel_path] = $scaffold_file;
+        $this->resolvedFileMappings[$package_name][$destination_rel_path] = $scaffold_file;
+      }
+    }
+  }
+
+  public function resolvedScaffoldFiles() {
+    return $this->resolvedFileMappings;
+  }
+
+  public function overridden(ScaffoldFileInfo $scaffold_file) {
+    $overriding_package = $this->findProvidingPackage($scaffold_file);
+    $overridden = $scaffold_file->overridden($overriding_package);
+    if ($overridden) {
+      $this->io->write($scaffold_file->interpolate("  - Skip <info>[dest-rel-path]</info>: overridden in <comment>{$overriding_package}</comment>"));
+    }
+    return $overridden;
+  }
+
+  /**
+   * Finds the package name that provides the scaffold file.
+   *
+   * This will usually be $scaffold_file->packageName(), unless there are
+   * multiple scaffold files with the same destination path. In the case
+   * that there are multiple scaffold files, the name of the last package
+   * that provided a scaffold file at that path will be returned.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFileInfo $scaffold_file
+   *   The scaffold file to use to find a providing package name.
+   *
+   * @return string
+   *   The name of the package that provided the scaffold file information.
+   */
+  protected function findProvidingPackage(ScaffoldFileInfo $scaffold_file) {
+    // The scaffold file should always be in our list, but we will check
+    // just to be sure that it really is.
+    $dest_rel_path = $scaffold_file->destination()->relativePath();
+    if (!array_key_exists($dest_rel_path, $this->listOfScaffoldFiles)) {
+      $msg = $scaffold_file->interpolate("  - Scaffold file [dest-rel-path] not found in list of all scaffold files.");
+      throw new \RuntimeException($msg);
+    }
+    return $this->listOfScaffoldFiles[$dest_rel_path]->packageName();
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php
new file mode 100644
index 0000000000..b542b1e1a3
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+
+/**
+ * Record the result of a scaffold operation.
+ */
+class ScaffoldResult {
+
+  /**
+   * The path to the scaffold file that was processed.
+   *
+   * @var \Drupal\Component\Scaffold\ScaffoldFilePath
+   */
+  protected $destination;
+
+  /**
+   * Indicates if this scaffold file is managed by the scaffold command.
+   *
+   * @var bool
+   */
+  protected $managed;
+
+  /**
+   * ScaffoldResult constructor.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   The path to the scaffold file that was processed.
+   * @param bool $isManaged
+   *   (optional) Whether this result is managed. Defaults to FALSE.
+   */
+  public function __construct(ScaffoldFilePath $destination, $isManaged = FALSE) {
+    $this->destination = $destination;
+    $this->managed = $isManaged;
+  }
+
+  /**
+   * Determines whether this scaffold file is managed.
+   *
+   * @return bool
+   *   TRUE if this scaffold file is managed, FALSE if not.
+   */
+  public function isManaged() {
+    return $this->managed;
+  }
+
+  /**
+   * Gets the destination scaffold file that this result refers to.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   The destination path for the scaffold result.
+   */
+  public function destination() {
+    return $this->destination;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php b/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php
new file mode 100644
index 0000000000..ce90cd95bf
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Scaffold operation to skip a scaffold file (do nothing).
+ */
+class SkipOp implements OperationInterface {
+
+  /**
+   * Identifies Skip operations.
+   */
+  const ID = 'skip';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $interpolator = $destination->getInterpolator();
+    $io->write($interpolator->interpolate("  - Skip <info>[dest-rel-path]</info>: disabled"));
+    return new ScaffoldResult($destination, FALSE);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Plugin.php b/core/lib/Drupal/Component/Scaffold/Plugin.php
new file mode 100644
index 0000000000..3762cfad06
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Plugin.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\IO\IOInterface;
+use Composer\Installer\PackageEvent;
+use Composer\Installer\PackageEvents;
+use Composer\Plugin\Capability\CommandProvider;
+use Composer\Plugin\Capable;
+use Composer\Plugin\CommandEvent;
+use Composer\Plugin\PluginEvents;
+use Composer\Plugin\PluginInterface;
+use Composer\Script\Event;
+use Composer\Script\ScriptEvents;
+use Drupal\Component\Scaffold\CommandProvider as ScaffoldCommandProvider;
+
+/**
+ * Composer plugin for handling drupal scaffold.
+ */
+class Plugin implements PluginInterface, EventSubscriberInterface, Capable {
+  /**
+   * The Composer Scaffold handler.
+   *
+   * @var \Drupal\Component\Scaffold\Handler
+   */
+  protected $handler;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Composer $composer, IOInterface $io) {
+    // We use a Handler object to separate the main functionality
+    // of this plugin from the Composer API. This also avoids some
+    // debug issues with the plugin being copied on initialisation.
+    // @see \Composer\Plugin\PluginManager::registerPackage()
+    $this->handler = new Handler($composer, $io);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCapabilities() {
+    return [CommandProvider::class => ScaffoldCommandProvider::class];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ScriptEvents::POST_UPDATE_CMD => 'postCmd',
+      ScriptEvents::POST_INSTALL_CMD => 'postCmd',
+      PackageEvents::POST_PACKAGE_INSTALL => 'postPackage',
+      PluginEvents::COMMAND => 'onCommand',
+    ];
+  }
+
+  /**
+   * Post command event callback.
+   *
+   * @param \Composer\Script\Event $event
+   *   The Composer event.
+   */
+  public function postCmd(Event $event) {
+    $this->handler->scaffold();
+  }
+
+  /**
+   * Post package event behaviour.
+   *
+   * @param \Composer\Installer\PackageEvent $event
+   *   Composer package event sent on install/update/remove.
+   */
+  public function postPackage(PackageEvent $event) {
+    $this->handler->onPostPackageEvent($event);
+  }
+
+  /**
+   * Pre command event callback.
+   *
+   * @param \Composer\Plugin\CommandEvent $event
+   *   The Composer command event.
+   */
+  public function onCommand(CommandEvent $event) {
+    if ($event->getCommandName() == 'require') {
+      $this->handler->beforeRequire($event);
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/PostPackageEventListenerInterface.php b/core/lib/Drupal/Component/Scaffold/PostPackageEventListenerInterface.php
new file mode 100644
index 0000000000..2285146f27
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/PostPackageEventListenerInterface.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Installer\PackageEvent;
+
+/**
+ * Interface for post package event listeners.
+ *
+ * @see \Drupal\Component\Scaffold\Handler::onPostPackageEvent
+ */
+interface PostPackageEventListenerInterface {
+
+  /**
+   * Handles package events during a 'composer require' operation.
+   *
+   * @param \Composer\Installer\PackageEvent $event
+   *   Composer package event sent on install/update/remove.
+   */
+  public function event(PackageEvent $event);
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/README.md b/core/lib/Drupal/Component/Scaffold/README.md
new file mode 100644
index 0000000000..d14c50d1b8
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/README.md
@@ -0,0 +1,487 @@
+# composer-scaffold
+
+This project provides a composer plugin for placing scaffold files (like
+`index.php`, `update.php`, …) from the `drupal/core` project into their desired
+location inside the web root. Only individual files may be scaffolded with this
+plugin.
+
+The purpose of scaffolding files is to allow Drupal sites to be fully managed by
+Composer, and still allow individual asset files to be placed in arbitrary
+locations. The goal of doing this is to enable a properly configured composer
+template to produce a file layout that exactly matches the file layout of a
+Drupal 8.7.x and earlier tarball distribution. Other file layouts will also be
+possible; for example, a project layout very similar to the current
+[drupal-composer/drupal-project](https://github.com/drupal-composer/drupal-scaffold)
+template will also be provided. When one of these projects is used, the user
+should be able to use `composer require` and `composer update` on a Drupal site
+immediately after untarring the downloaded archive.
+
+Note that the dependencies of a Drupal site are only able to scaffold files if
+explicitly granted that right in the top-level composer.json file. See
+[allowed packages](#allowed-packages), below.
+
+## Usage
+
+Composer-scaffold is used by requiring `drupal/core-composer-scaffold` in your
+project, and providing configuration settings in the `extra` section of your
+project's composer.json file. Additional configuration from the composer.json
+file of your project's dependencies is also consulted in order to scaffold the
+files a project needs. Additional information may be added to the beginning or
+end of scaffold files, as is commonly done to `.htaccess` and `robots.txt`
+files. See [altering scaffold files](#altering-scaffold-files) for more
+information.
+
+### Allowed Packages
+
+Scaffold files are stored inside of projects that are required from the main
+project's composer.json file as usual. The scaffolding operation happens after
+`composer install`, and involves copying or symlinking the desired assets to
+their destination location. In order to prevent arbitrary dependencies from
+copying files via the scaffold mechanism, only those projects that are
+specifically permitted by the top-level project will be used to scaffold files.
+
+Example: Permit scaffolding from the project `drupal/core`
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "drupal/core",
+      ],
+      ...
+    }
+  }
+```
+Allowing a package to scaffold files also permits it to delegate permission to
+scaffold to any project that it requires itself. This allows a package to
+organize its scaffold assets as it sees fit. For example, the project
+`drupal/core` may choose to store its assets in a subproject `drupal/assets`.
+
+It is possible for a project to obtain scaffold files from multiple projects.
+For example, a Drupal project using a distribution, and installing on a specific
+web hosting service provider might take its scaffold files from:
+
+- Drupal core
+- Its distribution
+- A project provided by the hosting provider
+- The project itself
+
+Each project allowed to scaffold by the top-level project will be used in turn,
+with projects declared later in the `allowed-packages` list taking precedence
+over the projects named before. The top-level composer.json itself is always
+implicitly allowed to scaffold files, and its scaffold files have highest
+priority.
+
+### Defining Project Locations
+
+The top-level project in turn must define where the web root is located. It does
+so via the `locations` mapping, as shown below:
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./docroot"
+      },
+      ...
+    }
+  }
+```
+This makes it possible to configure a project with different file layouts; for
+example, either the `drupal/drupal` file layout or the
+`drupal-composer/drupal-project` file layout could be used to set up a project.
+
+If a web-root is not explicitly defined, then it will default to `./`.
+
+### Altering Scaffold Files
+
+Sometimes, a project might wish to use a scaffold file provided by a dependency,
+but alter it in some way. Two forms of alteration are supported: appending and
+patching.
+
+The example below shows a project that appends additional entries onto the end
+of the `robots.txt` file provided by `drupal/core`:
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": {
+          "append": "assets/my-robots-additions.txt",
+        }
+      }
+    }
+  }
+```
+It is also possible to prepend to a scaffold file instead of, or in addition to
+appending by including a "prepend" entry that provides the relative path to the
+file to prepend to the scaffold file.
+
+The example below demonstrates the use of the `post-composer-scaffold-cmd` hook
+to patch the `.htaccess` file using a patch.
+```
+  "name": "my/project",
+  ...
+  "scripts": {
+    "post-composer-scaffold-cmd": [
+      "cd docroot && patch -p1 <../patches/htaccess-ssl.patch"
+    ]
+  }
+```
+
+### Defining Scaffold Files
+
+The placement of scaffold assets is under the control of the project that
+provides them, but the location is always relative to some directory defined by
+the root project -- usually the web root. For example, the scaffold file
+`robots.txt` is copied from its source location, `assets/robots.txt` into the
+web root in the snippet below.
+```
+{
+  "name": "drupal/assets",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": "assets/robots.txt",
+        ...
+      }
+    }
+  }
+}
+```
+
+### Excluding Scaffold Files
+
+Sometimes, a project might prefer to entirely replace a scaffold file provided
+by a dependency, and receive no further updates for it. This can be done by
+setting the value for the scaffold file to exclude to `false`:
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": false
+      }
+    }
+  }
+```
+If possible, use the `append` and `prepend` directives as explained in [altering
+scaffold files](#altering-scaffold-files), above. Excluding a file means that
+your project will not get any bug fixes or other updates to files that are
+modified locally.
+
+### Overwrite
+
+By default, scaffold files overwrite whatever content exists at the target
+location. Sometimes a project may wish to provide the initial contents for a
+file that will not be changed in subsequent updates. This can be done by setting
+the `overwrite` flag to `false`, as shown in the example below:
+```
+{
+  "name": "service-provider/d8-scaffold-files",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/sites/default/settings.php": {
+          "mode": "replace",
+          "path": "assets/sites/default/settings.php",
+          "overwrite": false
+        }
+      }
+    }
+  }
+}
+```
+Note that the `overwrite` directive is intended to be used by starter kits,
+service providers, and so on. Individual Drupal sites should exclude the file
+by setting its value to false instead.
+
+### Autoload File
+
+The scaffold tool automatically creates the required `autoload.php` file at the 
+Drupal root as part of the scaffolding operation. This file should not be 
+modified or customized in any way. If it is committed to the repository, though,
+then the scaffold tool will stop managing it. If the location of the `vendor`
+directory is changed for any reason, and the `autoload.php` file has been
+committed to the repository, manually delete it and then run `composer install`
+to update it.
+
+## Specifications
+
+Reference section for the configuration directives for the "composer-scaffold"
+section of the "extra" section of a `composer.json` file appear below.
+
+### allowed-packages
+
+The `allowed-packages` configuration setting contains an ordered list of package
+names that will be used during the scaffolding phase.
+```
+"allowed-packages": [
+  "drupal/core",
+],
+```
+### file-mapping
+
+The `file-mapping` configuration setting consists of a map from the destination
+path of the file to scaffold to a set of properties that control how the file
+should be scaffolded.
+
+The available properties are as follows:
+
+- mode: One of "replace", "append" or "skip".
+- path: The path to the source file to write over the destination file.
+- prepend: The path to the source file to prepend to the destination file, which
+  must always be a scaffold file provided by some other project.
+- append: Like `prepend`, but appends content rather than prepends.
+- overwrite: If `false`, prevents a `replace` from happening if the destination
+  already exists.
+
+The mode may be inferred from the other properties. If the mode is not
+specified, then the following defaults will be supplied:
+
+- replace: Selected if a `path` property is present, or if the entry's value is
+  a string rather than a property set.
+- append: Selected if a `prepend` or `append` property is present.
+- skip: Selected if the entry's value is a boolean `false`.
+
+Examples:
+```
+"file-mapping": {
+  "[web-root]/sites/default/default.settings.php": {
+    "mode": "replace",
+    "path": "assets/sites/default/default.settings.php",
+    "overwrite": true
+  },
+  "[web-root]/sites/default/settings.php": {
+    "mode": "replace",
+    "path": "assets/sites/default/settings.php",
+    "overwrite": false
+  },
+  "[web-root]/robots.txt": {
+    "mode": "append",
+    "prepend": "assets/robots-prequel.txt",
+    "append": "assets/robots-append.txt"
+  },
+  "[web-root]/.htaccess": {
+    "mode": "skip",
+  }
+}
+```
+The short-form of the above example would be:
+```
+"file-mapping": {
+  "[web-root]/sites/default/default.settings.php": "assets/sites/default/default.settings.php",
+  "[web-root]/sites/default/settings.php": {
+    "path": "assets/sites/default/settings.php",
+    "overwrite": false
+  },
+  "[web-root]/robots.txt": {
+    "prepend": "assets/robots-prequel.txt",
+    "append": "assets/robots-append.txt"
+  },
+  "[web-root]/.htaccess": false
+}
+```
+Note that there is no distinct "prepend" mode; "append" mode is used to both
+append and prepend to scaffold files. The reason for this is that scaffold file
+entries are identified in the file-mapping section keyed by their destination
+path, and it is not possible for multiple entries to have the same key. If
+"prepend" were a separate mode, then it would not be possible to both prepend
+and append to the same file.
+
+### gitignore
+
+The `gitignore` configuration setting controls whether or not this plugin will
+manage `.gitignore` files for files written during the scaffold operation.
+
+- true: `.gitignore` files will be updated when scaffold files are written.
+- false: `.gitignore` files will never be modified.
+- Not set: `.gitignore` files will be updated if the target directory is a local
+working copy of a git repository, and the `vendor` directory is not committed
+in that repository.
+
+### locations
+
+The `locations` configuration setting contains a list of named locations that
+may be used in placing scaffold files. The only required location is `web-root`.
+Other locations may also be defined if desired.
+```
+"locations": {
+  "web-root": "./docroot"
+},
+```
+### symlink
+
+The `symlink` property causes `replace` operations to make a symlink to the
+source file rather than copying it. This is useful when doing core development,
+as the symlink files themselves should not be edited. Note that `append`
+operations override the `symlink` option, to prevent the original scaffold
+assets from being altered.
+```
+"symlink": true,
+```
+## Managing Scaffold Files
+
+Scaffold files should be treated the same way that the `vendor` directory is
+handled. If you need to commit `vendor` (e.g. in order to deploy your site),
+then you should also commit your scaffold files. You should not commit your
+`vendor` directory or scaffold files unless it is necessary.
+
+If a dependency provides a scaffold file with `overwrite` set to `false`, that
+file should be committed to your repository.
+
+By default, `.gitignore` files will be automatically updated if needed when
+scaffold files are written. See the `gitignore` setting in the Specifications
+section above.
+
+## Examples
+
+Some full-length examples appear below.
+
+Sample composer.json for a project that relies on packages that use composer-scaffold:
+```
+{
+  "name": "my/project",
+  "require": {
+    "drupal/composer-scaffold": "*",
+    "composer/installers": "^1.2",
+    "cweagans/composer-patches": "^1.6.5",
+    "drupal/core": "^8.8.x-dev",
+    "service-provider/d8-scaffold-files": "^1"
+  },
+  "config": {
+    "optimize-autoloader": true,
+    "sort-packages": true
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "drupal/core",
+      ],
+      "locations": {
+        "web-root": "./docroot"
+      },
+      "symlink": true,
+      "overwrite": true,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    }
+  }
+}
+```
+
+Sample composer.json for drupal/core, with assets placed in a different project:
+
+```
+{
+  "name": "drupal/core",
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "drupal/assets",
+      ]
+    }
+  }
+}
+```
+
+Sample composer.json for composer-scaffold files in drupal/assets:
+
+```
+{
+  "name": "drupal/assets",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/.csslintrc": "assets/.csslintrc",
+        "[web-root]/.editorconfig": "assets/.editorconfig",
+        "[web-root]/.eslintignore": "assets/.eslintignore",
+        "[web-root]/.eslintrc.json": "assets/.eslintrc.json",
+        "[web-root]/.gitattributes": "assets/.gitattributes",
+        "[web-root]/.ht.router.php": "assets/.ht.router.php",
+        "[web-root]/.htaccess": "assets/.htaccess",
+        "[web-root]/sites/default/default.services.yml": "assets/default.services.yml",
+        "[web-root]/sites/default/default.settings.php": "assets/default.settings.php",
+        "[web-root]/sites/example.settings.local.php": "assets/example.settings.local.php",
+        "[web-root]/sites/example.sites.php": "assets/example.sites.php",
+        "[web-root]/index.php": "assets/index.php",
+        "[web-root]/robots.txt": "assets/robots.txt",
+        "[web-root]/update.php": "assets/update.php",
+        "[web-root]/web.config": "assets/web.config"
+      }
+    }
+  }
+}
+```
+
+Sample composer.json for a library that implements composer-scaffold:
+
+```
+{
+  "name": "service-provider/d8-scaffold-files",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/sites/default/settings.php": "assets/sites/default/settings.php"
+      }
+    }
+  }
+}
+```
+
+Append to robots.txt:
+
+```
+{
+  "name": "service-provider/d8-scaffold-files",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": {
+          "append": "assets/my-robots-additions.txt",
+        }
+      }
+    }
+  }
+}
+```
+
+Patch a file after it's copied:
+
+```
+"post-composer-scaffold-cmd": [
+  "cd docroot && patch -p1 <../patches/htaccess-ssl.patch"
+]
+```
+
+## Related Plugins
+
+### drupal-composer/drupal-scaffold
+
+Previous versions of drupal-scaffold (see community project,
+[drupal-composer/drupal-scaffold](https://github.com/drupal-composer/drupal-project))
+downloaded each scaffold file directly from its distribution server (e.g.
+`https://cgit.drupalcode.org`) to the desired destination directory. This was
+necessary, because there was no subtree split of the scaffold files available.
+Copying the scaffold assets from projects already downloaded by Composer is more
+effective, as downloading and unpacking archive files is more efficient than
+downloading each scaffold file individually.
+
+### composer/installers
+
+The [composer/installers](https://github.com/composer/installers) plugin is
+similar to this plugin in that it allows dependencies to be installed in
+locations other than the `vendor` directory. However, Composer and the
+`composer/installers` plugin have a limitation that one project cannot be moved
+inside of another project. Therefore, if you use `composer/installers` to place
+Drupal modules inside the directory `web/modules/contrib`, then you cannot also
+use `composer/installers` to place files such as `index.php` and `robots.txt`
+into the `web` directory. The drupal-scaffold plugin was created to work around
+this limitation.
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldFileInfo.php b/core/lib/Drupal/Component/Scaffold/ScaffoldFileInfo.php
new file mode 100644
index 0000000000..a085179afe
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldFileInfo.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\Operations\OperationInterface;
+
+/**
+ * Data object that keeps track of one scaffold file.
+ *
+ * Scaffold files are identified primarily by their destination path. Each
+ * scaffold file also has an 'operation' object that controls how the scaffold
+ * file will be placed (e.g. via copy or symlink, or maybe by appending multiple
+ * files together). The operation may have one or more source files.
+ */
+class ScaffoldFileInfo {
+
+  /**
+   * The path to the destination.
+   *
+   * @var \Drupal\Component\Scaffold\ScaffoldFilePath
+   */
+  protected $destination;
+
+  /**
+   * The operation used to create the destination.
+   *
+   * @var \Drupal\Component\Scaffold\Operations\OperationInterface
+   */
+  protected $op;
+
+  /**
+   * Constructs a ScaffoldFileInfo object.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   The full and relative paths to the destination file and the package
+   *   defining it.
+   * @param \Drupal\Component\Scaffold\Operations\OperationInterface $op
+   *   Operations object that will handle scaffolding operations.
+   */
+  public function __construct(ScaffoldFilePath $destination, OperationInterface $op) {
+    $this->destination = $destination;
+    $this->op = $op;
+  }
+
+  /**
+   * Gets the Scaffold operation.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   Operations object that handles scaffolding (copy, make symlink, etc).
+   */
+  public function op() {
+    return $this->op;
+  }
+
+  /**
+   * Gets the package name.
+   *
+   * @return string
+   *   The name of the package this scaffold file info was collected from.
+   */
+  public function packageName() {
+    return $this->destination->packageName();
+  }
+
+  /**
+   * Gets the destination.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   The scaffold path to the destination file.
+   */
+  public function destination() {
+    return $this->destination;
+  }
+
+  /**
+   * Determines if this scaffold file has been overridden by another package.
+   *
+   * @param string $providing_package
+   *   The name of the package that provides the scaffold file at this location,
+   *   as returned by self::findProvidingPackage()
+   *
+   * @return bool
+   *   Whether this scaffold file if overridden or removed.
+   */
+  public function overridden($providing_package) {
+    return $this->packageName() !== $providing_package;
+  }
+
+  /**
+   * Replaces placeholders in a message.
+   *
+   * @param string $message
+   *   Message with placeholders to fill in.
+   * @param array $extra
+   *   Additional data to merge with the interpolator.
+   * @param mixed $default
+   *   Default value to use for missing placeholders, or FALSE to keep them.
+   *
+   * @return string
+   *   Interpolated string with placeholders replaced.
+   */
+  public function interpolate($message, array $extra = [], $default = FALSE) {
+    $interpolator = $this->destination->getInterpolator();
+    return $interpolator->interpolate($message, $extra, $default);
+  }
+
+  /**
+   * Moves a single scaffold file from source to destination.
+   *
+   * @param \Composer\IO\IOInterface $io
+   *   The scaffold file to be processed.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Assorted operational options, e.g. whether the destination should be a
+   *   symlink.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+   *   The scaffold result.
+   */
+  public function process(IOInterface $io, ScaffoldOptions $options) {
+    return $this->op()->process($this->destination, $io, $options);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php b/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php
new file mode 100644
index 0000000000..8e842a68e1
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+/**
+ * Manage the path to a file to scaffold.
+ *
+ * Both the relative and full path to the file is maintained so that the shorter
+ * name may be used in progress and error messages, as needed. The name of the
+ * package that provided the file path is also recorded for the same reason.
+ *
+ * ScaffoldFilePaths may be used to represent destination scaffold files, or the
+ * source files used to create them. Static factory methods named
+ * destinationPath and sourcePath, respectively, are provided to create
+ * ScaffoldFilePath objects.
+ */
+class ScaffoldFilePath {
+
+  /**
+   * The type of scaffold file this is, 'src' or 'dest'.
+   *
+   * @var string
+   */
+  protected $type;
+
+  /**
+   * The name of the package containing the file.
+   *
+   * @var string
+   */
+  protected $packageName;
+
+  /**
+   * The relative path to the file.
+   *
+   * @var string
+   */
+  protected $relativePath;
+
+  /**
+   * The full path to the file.
+   *
+   * @var string
+   */
+  protected $fullPath;
+
+  /**
+   * ScaffoldFilePath constructor.
+   *
+   * @param string $path_type
+   *   The type of scaffold file this is, 'src' or 'dest'.
+   * @param string $package_name
+   *   The name of the package containing the file.
+   * @param string $rel_path
+   *   The relative path to the file.
+   * @param string $full_path
+   *   The full path to the file.
+   */
+  public function __construct($path_type, $package_name, $rel_path, $full_path) {
+    $this->type = $path_type;
+    $this->packageName = $package_name;
+    $this->relativePath = $rel_path;
+    $this->fullPath = $full_path;
+  }
+
+  /**
+   * Gets the name of the package this source file was pulled from.
+   *
+   * @return string
+   *   Name of package.
+   */
+  public function packageName() {
+    return $this->packageName;
+  }
+
+  /**
+   * Gets the relative path to the source file (best to use in messages).
+   *
+   * @return string
+   *   Relative path to file.
+   */
+  public function relativePath() {
+    return $this->relativePath;
+  }
+
+  /**
+   * Gets the full path to the source file.
+   *
+   * @return string
+   *   Full path to file.
+   */
+  public function fullPath() {
+    return $this->fullPath;
+  }
+
+  /**
+   * Converts the relative source path into an absolute path.
+   *
+   * The path returned will be relative to the package installation location.
+   *
+   * @param string $package_name
+   *   The name of the package containing the source file. Only used for error
+   *   messages.
+   * @param string $package_path
+   *   The installation path of the package containing the source file.
+   * @param string $destination
+   *   Destination location provided as a relative path. Only used for error
+   *   messages.
+   * @param string $source
+   *   Source location provided as a relative path.
+   *
+   * @return self
+   *   Object wrapping the relative and absolute path to the source file.
+   */
+  public static function sourcePath($package_name, $package_path, $destination, $source) {
+    // Complain if there is no source path.
+    if (empty($source)) {
+      throw new \RuntimeException("No scaffold file path given for {$destination} in package {$package_name}.");
+    }
+    // Calculate the full path to the source scaffold file.
+    $source_full_path = $package_path . '/' . $source;
+    if (!file_exists($source_full_path)) {
+      throw new \RuntimeException("Scaffold file {$source} not found in package {$package_name}.");
+    }
+    if (is_dir($source_full_path)) {
+      throw new \RuntimeException("Scaffold file {$source} in package {$package_name} is a directory; only files may be scaffolded.");
+    }
+    return new self('src', $package_name, $source, $source_full_path);
+  }
+
+  /**
+   * Converts the relative destination path into an absolute path.
+   *
+   * Any placeholders in the destination path, e.g. '[web-root]', will be
+   * replaced using the provided location replacements interpolator.
+   *
+   * @param string $package_name
+   *   The name of the package defining the destination path.
+   * @param string $destination
+   *   The relative path to the destination file being scaffolded.
+   * @param \Drupal\Component\Scaffold\Interpolator $location_replacements
+   *   Interpolator that includes the [web-root] and any other available
+   *   placeholder replacements.
+   *
+   * @return self
+   *   Object wrapping the relative and absolute path to the destination file.
+   */
+  public static function destinationPath($package_name, $destination, Interpolator $location_replacements) {
+    $dest_full_path = $location_replacements->interpolate($destination);
+    return new self('dest', $package_name, $destination, $dest_full_path);
+  }
+
+  /**
+   * Adds data about the relative and full path to the provided interpolator.
+   *
+   * @param \Drupal\Component\Scaffold\Interpolator $interpolator
+   *   Interpolator to add data to.
+   * @param string $name_prefix
+   *   (optional) Prefix to add before -rel-path and -full-path item names.
+   *   Defaults to path type provided when constructing this object.
+   */
+  public function addInterpolationData(Interpolator $interpolator, $name_prefix = '') {
+    if (empty($name_prefix)) {
+      $name_prefix = $this->type;
+    }
+    $data = [
+      'package-name' => $this->packageName(),
+      "{$name_prefix}-rel-path" => $this->relativePath(),
+      "{$name_prefix}-full-path" => $this->fullPath(),
+    ];
+    $interpolator->addData($data);
+  }
+
+  /**
+   * Interpolate a string using the data from this scaffold file info.
+   *
+   * @param string $name_prefix
+   *   (optional) Prefix to add before -rel-path and -full-path item names.
+   *   Defaults to path type provided when constructing this object.
+   *
+   * @return \Drupal\Component\Scaffold\Interpolator
+   *   An interpolator for making string replacements.
+   */
+  public function getInterpolator($name_prefix = '') {
+    $interpolator = new Interpolator();
+    $this->addInterpolationData($interpolator, $name_prefix);
+    return $interpolator;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php b/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php
new file mode 100644
index 0000000000..9d8e2be273
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+/**
+ * Per-project options from the 'extras' section of the composer.json file.
+ *
+ * Projects that describe scaffold files do so via their scaffold options. This
+ * data is pulled from the 'composer-scaffold' portion of the extras section of
+ * the project data.
+ */
+class ScaffoldOptions {
+
+  /**
+   * The raw data from the 'extras' section of the top-level composer.json file.
+   *
+   * @var array
+   */
+  protected $options = [];
+
+  /**
+   * ScaffoldOptions constructor.
+   *
+   * @param array $options
+   *   The scaffold options taken from the 'composer-scaffold' section.
+   */
+  protected function __construct(array $options) {
+    $this->options = $options + [
+      "allowed-packages" => [],
+      "locations" => [],
+      "symlink" => FALSE,
+      "file-mapping" => [],
+    ];
+
+    // Define any default locations.
+    $this->options['locations'] += [
+      'web-root' => '.',
+    ];
+  }
+
+  /**
+   * Determines if the provided 'extras' section has scaffold options.
+   *
+   * @param array $extras
+   *   The contents of the 'extras' section.
+   *
+   * @return bool
+   *   True if scaffold options have been declared
+   */
+  public static function hasOptions(array $extras) {
+    return array_key_exists('composer-scaffold', $extras);
+  }
+
+  /**
+   * Creates a scaffold options object.
+   *
+   * @param array $extras
+   *   The contents of the 'extras' section.
+   *
+   * @return self
+   *   The scaffold options object representing the provided scaffold options
+   */
+  public static function create(array $extras) {
+    $options = static::hasOptions($extras) ? $extras['composer-scaffold'] : [];
+    return new self($options);
+  }
+
+  /**
+   * Creates a new scaffold options object with some values overridden.
+   *
+   * @param array $options
+   *   Override values.
+   *
+   * @return self
+   *   The scaffold options object representing the provided scaffold options
+   */
+  protected function override(array $options) {
+    return new self($options + $this->options);
+  }
+
+  /**
+   * Creates a new scaffold options object with an overridden 'symlink' value.
+   *
+   * @param bool $symlink
+   *   Whether symlinking should be enabled or not.
+   *
+   * @return self
+   *   The scaffold options object representing the provided scaffold options
+   */
+  public function overrideSymlink($symlink) {
+    return $this->override(['symlink' => $symlink]);
+  }
+
+  /**
+   * Determines whether any allowed packages were defined.
+   *
+   * @return bool
+   *   Whether there are allowed packages
+   */
+  public function hasAllowedPackages() {
+    return !empty($this->allowedPackages());
+  }
+
+  /**
+   * Gets allowed packages from these options.
+   *
+   * @return array
+   *   The list of allowed packages
+   */
+  public function allowedPackages() {
+    return $this->options['allowed-packages'];
+  }
+
+  /**
+   * Gets the location mapping table, e.g. 'webroot' => './'.
+   *
+   * @return array
+   *   A map of name : location values
+   */
+  public function locations() {
+    return $this->options['locations'];
+  }
+
+  /**
+   * Determines whether a given named location is defined.
+   *
+   * @param string $name
+   *   The location name to search for.
+   *
+   * @return bool
+   *   True if the specified named location exist.
+   */
+  protected function hasLocation($name) {
+    return array_key_exists($name, $this->locations());
+  }
+
+  /**
+   * Gets a specific named location.
+   *
+   * @param string $name
+   *   The name of the location to fetch.
+   *
+   * @return string
+   *   The value of the provided named location
+   */
+  public function getLocation($name) {
+    return $this->hasLocation($name) ? $this->locations()[$name] : FALSE;
+  }
+
+  /**
+   * Determines if symlink mode is set.
+   *
+   * @return bool
+   *   Whether or not 'symlink' mode
+   */
+  public function symlink() {
+    return $this->options['symlink'];
+  }
+
+  /**
+   * Determines if there are file mappings.
+   *
+   * @return bool
+   *   Whether or not the scaffold options contain any file mappings
+   */
+  public function hasFileMapping() {
+    return !empty($this->fileMapping());
+  }
+
+  /**
+   * Returns the actual file mappings.
+   *
+   * @return array
+   *   File mappings for just this config type.
+   */
+  public function fileMapping() {
+    return $this->options['file-mapping'];
+  }
+
+  /**
+   * Determines if there is defined a value for the 'gitignore' option.
+   *
+   * @return bool
+   *   Whether or not there is a 'gitignore' option setting
+   */
+  public function hasGitIgnore() {
+    return isset($this->options['gitignore']);
+  }
+
+  /**
+   * Gets the value of the 'gitignore' option.
+   *
+   * @return bool
+   *   The 'gitignore' option, or TRUE if undefined.
+   */
+  public function gitIgnore() {
+    return $this->hasGitIgnore() ? $this->options['gitignore'] : TRUE;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/TESTING.txt b/core/lib/Drupal/Component/Scaffold/TESTING.txt
new file mode 100644
index 0000000000..3186f0f901
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/TESTING.txt
@@ -0,0 +1,18 @@
+HOW-TO: Test this Drupal component
+
+In order to test this component, you'll need to get the entire Drupal repo and
+run the tests there.
+
+You'll find the tests under core/tests/Drupal/Tests/Component.
+
+You can get the full Drupal repo here:
+https://www.drupal.org/project/drupal/git-instructions
+
+You can find more information about running PHPUnit tests with Drupal here:
+https://www.drupal.org/node/2116263
+
+Each component in the Drupal\Component namespace has its own annotated test
+group. You can use this group to run only the tests for this component. Like
+this:
+
+$ ./vendor/bin/phpunit -c core --group Scaffold
diff --git a/core/lib/Drupal/Component/Scaffold/composer.json b/core/lib/Drupal/Component/Scaffold/composer.json
new file mode 100644
index 0000000000..e826f21d36
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/composer.json
@@ -0,0 +1,29 @@
+{
+  "name": "drupal/core-composer-scaffold",
+  "description": "A flexible Composer project scaffold builder.",
+  "type": "composer-plugin",
+  "keywords": ["drupal"],
+  "homepage": "https://www.drupal.org/project/drupal",
+  "license": "GPL-2.0-or-later",
+  "require": {
+    "composer-plugin-api": "^1.0.0",
+    "php": ">=7.0.8"
+  },
+  "autoload": {
+    "psr-4": {
+      "Drupal\\Component\\Scaffold\\": ""
+    }
+  },
+  "extra": {
+    "class": "Drupal\\Component\\Scaffold\\Plugin",
+    "branch-alias": {
+      "dev-master": "1.0.x-dev"
+    }
+  },
+  "config": {
+    "sort-packages": true
+  },
+  "require-dev": {
+    "composer/composer": "^1.8@stable"
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php b/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php
new file mode 100644
index 0000000000..28041c69ff
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold;
+
+/**
+ * Convenience class for creating fixtures.
+ */
+trait AssertUtilsTrait {
+
+  /**
+   * Asserts that a given file exists and is/is not a symlink.
+   *
+   * @param string $path
+   *   The path to check exists.
+   * @param bool $is_link
+   *   Checks if the file should be a symlink or not.
+   * @param string $contents_contains
+   *   Regex to check the file contents.
+   */
+  protected function assertScaffoldedFile($path, $is_link, $contents_contains) {
+    $this->assertFileExists($path);
+    $contents = file_get_contents($path);
+    $this->assertContains($contents_contains, basename($path) . ': ' . $contents);
+    $this->assertSame($is_link, is_link($path));
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php b/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php
new file mode 100644
index 0000000000..65681d7b6f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold;
+
+use Symfony\Component\Process\Process;
+
+/**
+ * Convenience class for creating fixtures.
+ */
+trait ExecTrait {
+
+  /**
+   * Runs an arbitrary command.
+   *
+   * @param string $cmd
+   *   The command to execute (escaped as required)
+   * @param string $cwd
+   *   The current working directory to run the command from.
+   * @param array $env
+   *   Environment variables to define for the subprocess.
+   *
+   * @return string
+   *   Standard output from the command
+   */
+  protected function mustExec($cmd, $cwd, array $env = []) {
+    $process = new Process($cmd, $cwd, $env + ['PATH' => getenv('PATH'), 'HOME' => getenv('HOME')]);
+    $process->inheritEnvironmentVariables();
+    $process->setTimeout(300)->setIdleTimeout(300)->run();
+    $exitCode = $process->getExitCode();
+    if (0 != $exitCode) {
+      throw new \RuntimeException("Exit code: {$exitCode}\n\n" . $process->getErrorOutput() . "\n\n" . $process->getOutput());
+    }
+    return $process->getOutput();
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php b/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php
new file mode 100644
index 0000000000..d66d7802f9
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php
@@ -0,0 +1,370 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold;
+
+use Composer\Console\Application;
+use Composer\Factory;
+use Composer\IO\BufferIO;
+use Composer\Util\Filesystem;
+use Drupal\Component\Scaffold\Handler;
+use Drupal\Component\Scaffold\Interpolator;
+use Drupal\Component\Scaffold\Operations\AppendOp;
+use Drupal\Component\Scaffold\Operations\ReplaceOp;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Symfony\Component\Console\Input\StringInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+/**
+ * Convenience class for creating fixtures.
+ */
+class Fixtures {
+
+  /**
+   * Directories to delete when we are done.
+   *
+   * @var string[]
+   */
+  protected $tmpDirs = [];
+
+  /**
+   * A Composer IOInterface to write to.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+
+  /**
+   * The composer object.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+
+  /**
+   * Gets an IO fixture.
+   *
+   * @return \Composer\IO\IOInterface
+   *   A Composer IOInterface to write to; output may be retrieved via
+   *   Fixtures::getOutput().
+   */
+  public function io() {
+    if (!$this->io) {
+      $this->io = new BufferIO();
+    }
+    return $this->io;
+  }
+
+  /**
+   * Gets the Composer object.
+   *
+   * @return \Composer\Composer
+   *   The main Composer object, needed by the scaffold Handler, etc.
+   */
+  public function getComposer() {
+    if (!$this->composer) {
+      $this->composer = Factory::create($this->io(), NULL, TRUE);
+    }
+    return $this->composer;
+  }
+
+  /**
+   * Gets the output from the io() fixture.
+   *
+   * @return string
+   *   Output captured from tests that write to Fixtures::io().
+   */
+  public function getOutput() {
+    return $this->io()->getOutput();
+  }
+
+  /**
+   * Gets the path to Scaffold component.
+   *
+   * Used to inject the component into composer.json files.
+   *
+   * @return string
+   *   Path to the root of this project.
+   */
+  public function projectRoot() {
+    return realpath(__DIR__) . '/../../../../../../core/lib/Drupal/Component/Scaffold';
+  }
+
+  /**
+   * Gets the path to the project fixtures.
+   *
+   * @return string
+   *   Path to project fixtures
+   */
+  public function allFixturesDir() {
+    return realpath(__DIR__ . '/fixtures');
+  }
+
+  /**
+   * Gets the path to one particular project fixture.
+   *
+   * @param string $project_name
+   *   The project name to get the path for.
+   *
+   * @return string
+   *   Path to project fixture.
+   */
+  public function projectFixtureDir($project_name) {
+    $dir = $this->allFixturesDir() . '/' . $project_name;
+    if (!is_dir($dir)) {
+      throw new \RuntimeException("Requested fixture project {$project_name} that does not exist.");
+    }
+    return $dir;
+  }
+
+  /**
+   * Gets the path to one particular bin path.
+   *
+   * @param string $bin_name
+   *   The bin name to get the path for.
+   *
+   * @return string
+   *   Path to project fixture.
+   */
+  public function binFixtureDir($bin_name) {
+    $dir = $this->allFixturesDir() . '/scripts/' . $bin_name;
+    if (!is_dir($dir)) {
+      throw new \RuntimeException("Requested fixture bin dir {$bin_name} that does not exist.");
+    }
+    return $dir;
+  }
+
+  /**
+   * Gets a path to a source scaffold fixture.
+   *
+   * Use in place of ScaffoldFilePath::sourcePath().
+   *
+   * @param string $project_name
+   *   The name of the project to fetch; $package_name is
+   *   "fixtures/$project_name".
+   * @param string $source
+   *   The name of the asset; path is "assets/$source".
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   The full and relative path to the desired asset
+   *
+   * @see \Drupal\Component\Scaffold\ScaffoldFilePath::sourcePath()
+   */
+  public function sourcePath($project_name, $source) {
+    $package_name = "fixtures/{$project_name}";
+    $source_rel_path = "assets/{$source}";
+    $package_path = $this->projectFixtureDir($project_name);
+    return ScaffoldFilePath::sourcePath($package_name, $package_path, 'unknown', $source_rel_path);
+  }
+
+  /**
+   * Gets an Interpolator with 'web-root' and 'package-name' set.
+   *
+   * Use in place of ManageOptions::getLocationReplacements().
+   *
+   * @return \Drupal\Component\Scaffold\Interpolator
+   *   An interpolator with location replacements, including 'web-root'.
+   *
+   * @see \Drupal\Component\Scaffold\ManageOptions::getLocationReplacements()
+   */
+  public function getLocationReplacements() {
+    $destinationTmpDir = $this->mkTmpDir();
+    $interpolator = new Interpolator();
+    $interpolator->setData(['web-root' => $destinationTmpDir, 'package-name' => 'fixtures/tmp-destination']);
+    return $interpolator;
+  }
+
+  /**
+   * Creates a ReplaceOp fixture.
+   *
+   * @param string $project_name
+   *   The name of the project to fetch; $package_name is
+   *   "fixtures/$project_name".
+   * @param string $source
+   *   The name of the asset; path is "assets/$source".
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ReplaceOp
+   *   A replace operation object.
+   */
+  public function replaceOp($project_name, $source) {
+    $source_path = $this->sourcePath($project_name, $source);
+    return new ReplaceOp($source_path, TRUE);
+  }
+
+  /**
+   * Creates an AppendOp fixture.
+   *
+   * @param string $project_name
+   *   The name of the project to fetch; $package_name is
+   *   "fixtures/$project_name".
+   * @param string $source
+   *   The name of the asset; path is "assets/$source".
+   *
+   * @return \Drupal\Component\Scaffold\Operations\AppendOp
+   *   An append operation object.
+   */
+  public function appendOp($project_name, $source) {
+    $source_path = $this->sourcePath($project_name, $source);
+    return new AppendOp(NULL, $source_path);
+  }
+
+  /**
+   * Gets a destination path in a tmp dir.
+   *
+   * Use in place of ScaffoldFilePath::destinationPath().
+   *
+   * @param string $destination
+   *   Destination path; should be in the form '[web-root]/robots.txt', where
+   *   '[web-root]' is always literally '[web-root]', with any arbitrarily
+   *   desired filename following.
+   * @param \Drupal\Component\Scaffold\Interpolator $interpolator
+   *   Location replacements. Obtain via Fixtures::getLocationReplacements()
+   *   when creating multiple scaffold destinations.
+   * @param string $package_name
+   *   (optional) The name of the fixture package that this path came from.
+   *   Taken from interpolator if not provided.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   A destination scaffold file backed by temporary storage.
+   *
+   * @see \Drupal\Component\Scaffold\ScaffoldFilePath::destinationPath()
+   */
+  public function destinationPath($destination, Interpolator $interpolator = NULL, $package_name = NULL) {
+    $interpolator = $interpolator ?: $this->getLocationReplacements();
+    $package_name = $package_name ?: $interpolator->interpolate('[package-name]');
+    return ScaffoldFilePath::destinationPath($package_name, $destination, $interpolator);
+  }
+
+  /**
+   * Generates a path to a temporary location, but do not create the directory.
+   *
+   * @param string $extraSalt
+   *   Extra characters to throw into the md5 to add to name.
+   *
+   * @return string
+   *   Path to temporary directory
+   */
+  public function tmpDir($extraSalt = '') {
+    $tmpDir = sys_get_temp_dir() . '/composer-scaffold-test-' . md5($extraSalt . microtime());
+    $this->tmpDirs[] = $tmpDir;
+    return $tmpDir;
+  }
+
+  /**
+   * Creates a temporary directory.
+   *
+   * @param string $extraSalt
+   *   Extra characters to throw into the md5 to add to name.
+   *
+   * @return string
+   *   Path to temporary directory
+   */
+  public function mkTmpDir($extraSalt = '') {
+    $tmpDir = $this->tmpDir($extraSalt);
+    $filesystem = new Filesystem();
+    $filesystem->ensureDirectoryExists($tmpDir);
+    return $tmpDir;
+  }
+
+  /**
+   * Calls 'tearDown' in any test that copies fixtures to transient locations.
+   */
+  public function tearDown() {
+    // Remove any temporary directories that were created.
+    $filesystem = new Filesystem();
+    foreach ($this->tmpDirs as $dir) {
+      $filesystem->remove($dir);
+    }
+    // Clear out variables from the previous pass.
+    $this->tmpDirs = [];
+    $this->io = NULL;
+  }
+
+  /**
+   * Creates a temporary copy of all of the fixtures projects into a temp dir.
+   *
+   * The fixtures remain dirty if they already exist. Individual tests should
+   * first delete any fixture directory that needs to remain pristine. Since all
+   * temporary directories are removed in tearDown, this is only an issue when
+   * a) the FIXTURE_DIR environment variable has been set, or b) tests are
+   * calling cloneFixtureProjects more than once per test method.
+   *
+   * @param string $fixturesDir
+   *   The directory to place fixtures in.
+   * @param array $replacements
+   *   Key : value mappings for placeholders to replace in composer.json
+   *   templates.
+   */
+  public function cloneFixtureProjects($fixturesDir, array $replacements = []) {
+    $filesystem = new Filesystem();
+    // We will replace 'SYMLINK' with the string 'true' in our composer.json
+    // fixture.
+    $replacements += ['SYMLINK' => 'true'];
+    $interpolator = new Interpolator('__', '__');
+    $interpolator->setData($replacements);
+    $filesystem->copy($this->allFixturesDir(), $fixturesDir);
+    $composer_json_templates = glob($fixturesDir . "/*/composer.json.tmpl");
+    foreach ($composer_json_templates as $composer_json_tmpl) {
+      // Inject replacements into composer.json.
+      if (file_exists($composer_json_tmpl)) {
+        $composer_json_contents = file_get_contents($composer_json_tmpl);
+        $composer_json_contents = $interpolator->interpolate($composer_json_contents, [], FALSE);
+        file_put_contents(dirname($composer_json_tmpl) . "/composer.json", $composer_json_contents);
+        @unlink($composer_json_tmpl);
+      }
+    }
+  }
+
+  /**
+   * Runs the scaffold operation.
+   *
+   * This is equivalent to running `composer composer-scaffold`, but we do the
+   * equivalent operation by instantiating a Handler object in order to continue
+   * running in the same process, so that coverage may be calculated for the
+   * code executed by these tests.
+   *
+   * @param string $cwd
+   *   The working directory to run the scaffold command in.
+   *
+   * @return string
+   *   Output captured from tests that write to Fixtures::io().
+   */
+  public function runScaffold($cwd) {
+    chdir($cwd);
+    $handler = new Handler($this->getComposer(), $this->io());
+    $handler->scaffold();
+    return $this->getOutput();
+  }
+
+  /**
+   * Runs a `composer` command.
+   *
+   * @param string $cmd
+   *   The Composer command to execute (escaped as required)
+   * @param string $cwd
+   *   The current working directory to run the command from.
+   * @param int $expectedExitCode
+   *   The expected exit code; will throw if a different exit code is returned.
+   *
+   * @return string
+   *   Standard output and standard error from the command.
+   */
+  public function runComposer($cmd, $cwd, $expectedExitCode = 0) {
+    chdir($cwd);
+    $input = new StringInput($cmd);
+    $output = new BufferedOutput();
+    $application = new Application();
+    $application->setAutoExit(FALSE);
+    try {
+      $exitCode = $application->run($input, $output);
+      if ($exitCode != $expectedExitCode) {
+        print "Command '{$cmd}' - Expected exit code: {$expectedExitCode}, actual exit code: {$exitCode}\n";
+      }
+    }
+    catch (\Exception $e) {
+      print "Exception: " . $e->getMessage() . "\n";
+    }
+    $output = $output->fetch();
+    return $output;
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php
new file mode 100644
index 0000000000..d703d5bd11
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Functional;
+
+use Composer\Util\Filesystem;
+use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
+use Drupal\Tests\Component\Scaffold\ExecTrait;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests Composer Hooks that run scaffold operations.
+ *
+ * The purpose of this test file is to exercise all of the different Composer
+ * commands that invoke scaffold operations, and ensure that files are
+ * scaffolded when they should be.
+ *
+ * Note that this test file uses `exec` to run Composer for a pure functional
+ * test. Other functional test files invoke Composer commands directly via the
+ * Composer Application object, in order to get more accurate test coverage
+ * information.
+ *
+ * @group Scaffold
+ */
+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.
+   *
+   * @var string
+   */
+  protected $fixturesDir;
+
+  /**
+   * The Symfony FileSystem component.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * The Fixtures object.
+   *
+   * @var \Drupal\Tests\Component\Scaffold\Fixtures
+   */
+  protected $fixtures;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->fileSystem = new Filesystem();
+    $this->fixtures = new Fixtures();
+    $this->projectRoot = $this->fixtures->projectRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    // Remove any temporary directories et. al. that were created.
+    $this->fixtures->tearDown();
+  }
+
+  /**
+   * Test to see if scaffold operation runs at the correct times.
+   */
+  public function testComposerHooks() {
+    $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+    $replacements = ['SYMLINK' => 'false', 'PROJECT_ROOT' => $this->projectRoot];
+    $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+    $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);
+    // 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');
+    // Delete the same test scaffold file again, then run
+    // 'composer composer: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');
+    // 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 composer:scaffold --no-ansi", $sut);
+    $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture');
+    // 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';
+    $filesystem = new Filesystem();
+    $filesystem->remove($sut);
+    $stdout = $this->mustExec("composer create-project --repository=packages.json fixtures/drupal-drupal {$sut}", $this->fixturesDir, ['COMPOSER_MIRROR_PATH_REPOS' => 1]);
+    $this->assertDirectoryExists($sut);
+    $this->assertContains('Scaffolding files for fixtures/drupal-drupal', $stdout);
+    $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);
+    $this->assertFileNotExists($sut . '/sites/default/default.settings.php');
+    $this->assertContains('Package fixtures/scaffold-override-fixture has scaffold operations, but it is not allowed in the root-level composer.json file.', $stdout);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php
new file mode 100644
index 0000000000..c27e3e2fdf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Functional;
+
+use Composer\Util\Filesystem;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
+use Drupal\Tests\Component\Scaffold\ExecTrait;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests to see whether .gitignore files are correctly managed.
+ *
+ * The purpose of this test file is to run a scaffold operation and
+ * confirm that the files that were scaffolded are added to the
+ * repository's .gitignore file.
+ *
+ * @group Scaffold
+ */
+class ManageGitIgnoreTest 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.
+   *
+   * @var string
+   */
+  protected $fixturesDir;
+
+  /**
+   * The Symfony FileSystem component.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * The Fixtures object.
+   *
+   * @var \Drupal\Tests\Component\Scaffold\Fixtures
+   */
+  protected $fixtures;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->fileSystem = new Filesystem();
+    $this->fixtures = new Fixtures();
+    $this->projectRoot = $this->fixtures->projectRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    // Remove any temporary directories et. al. that were created.
+    $this->fixtures->tearDown();
+  }
+
+  /**
+   * Creates a system-under-test and initialize a git repository for it.
+   *
+   * @param string $fixture_name
+   *   The name of the fixture to use from
+   *   core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+   *
+   * @return string
+   *   The path to the fixture directory.
+   */
+  protected function createSutWithGit($fixture_name) {
+    $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+    $sut = $this->fixturesDir . '/' . $fixture_name;
+    $replacements = ['SYMLINK' => 'false', 'PROJECT_ROOT' => $this->projectRoot];
+    $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+    // .gitignore files will not be managed unless there is a git repository.
+    $this->mustExec('git init', $sut);
+    // Add some user info so git does not complain.
+    $this->mustExec('git config user.email "test@example.com"', $sut);
+    $this->mustExec('git config user.name "Test User"', $sut);
+    $this->mustExec('git add .', $sut);
+    $this->mustExec('git commit -m "Initial commit."', $sut);
+    // Run composer install, but suppress scaffolding.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+    return $sut;
+  }
+
+  /**
+   * Tests scaffold command correctly manages the .gitignore file.
+   */
+  public function testManageGitIgnore() {
+    // Note that the drupal-composer-drupal-project fixture does not
+    // have any configuration settings related to .gitignore management.
+    $sut = $this->createSutWithGit('drupal-composer-drupal-project');
+    $this->assertFileNotExists($sut . '/docroot/index.php');
+    $this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
+    // Run the scaffold command.
+    $this->fixtures->runScaffold($sut);
+    $this->assertFileExists($sut . '/docroot/index.php');
+    $expected = <<<EOT
+build
+.csslintrc
+.editorconfig
+.eslintignore
+.eslintrc.json
+.gitattributes
+.ht.router.php
+autoload.php
+index.php
+robots.txt
+update.php
+web.config
+EOT;
+    // At this point we should have a .gitignore file, because although we did
+    // not explicitly ask for .gitignore tracking, the vendor directory is not
+    // tracked, so the default in that instance is to manage .gitignore files.
+    $this->assertScaffoldedFile($sut . '/docroot/.gitignore', FALSE, $expected);
+    $this->assertScaffoldedFile($sut . '/docroot/sites/.gitignore', FALSE, 'example.settings.local.php');
+    $this->assertScaffoldedFile($sut . '/docroot/sites/default/.gitignore', FALSE, 'default.services.yml');
+    $expected = <<<EOT
+M docroot/.gitignore
+?? docroot/sites/.gitignore
+?? docroot/sites/default/.gitignore
+EOT;
+    // Check to see whether there are any untracked files. We expect that
+    // only the .gitignore files themselves should be untracked.
+    $stdout = $this->mustExec('git status --porcelain', $sut);
+    $this->assertEquals(trim($expected), trim($stdout));
+  }
+
+  /**
+   * Tests scaffold command does not manage the .gitignore file when disabled.
+   */
+  public function testUnmanagedGitIgnoreWhenDisabled() {
+    // Note that the drupal-drupal fixture has a configuration setting
+    // `"gitignore": false,` which disables .gitignore file handling.
+    $sut = $this->createSutWithGit('drupal-drupal');
+    $this->assertFileNotExists($sut . '/docroot/index.php');
+    // Run the scaffold command.
+    $this->fixtures->runScaffold($sut);
+    $this->assertFileExists($sut . '/index.php');
+    $this->assertFileNotExists($sut . '/.gitignore');
+    $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+  }
+
+  /**
+   * Tests scaffold command disables .gitignore management when git not present.
+   *
+   * The scaffold operation should still succeed if there is no 'git'
+   * executable.
+   */
+  public function testUnmanagedGitIgnoreWhenGitNotAvailable() {
+    // Note that the drupal-composer-drupal-project fixture does not have any
+    // configuration settings related to .gitignore management.
+    $sut = $this->createSutWithGit('drupal-composer-drupal-project');
+    $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+    $this->assertFileNotExists($sut . '/docroot/index.php');
+    $this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
+    // Confirm that 'git' is available (n.b. if it were not, createSutWithGit()
+    // would fail).
+    exec('git --help', $output, $status);
+    $this->assertEquals(0, $status);
+    // Modify our $PATH so that it begins with a path that contains an
+    // executable script named 'git' that always exits with 127, as if git were
+    // not found. Note that we run our tests using process isolation, so we do
+    // not need to restore the PATH when we are done.
+    $unavailableGitPath = $this->fixtures->binFixtureDir('disable-git-bin');
+    chmod($unavailableGitPath . '/git', 0755);
+    putenv('PATH=' . $unavailableGitPath . ':' . getenv('PATH'));
+    // Confirm that 'git' is no longer available.
+    exec('git --help', $output, $status);
+    $this->assertEquals(127, $status);
+    // Run the scaffold command.
+    exec('composer composer:scaffold', $output, $status);
+    $this->assertFileExists($sut . '/docroot/index.php');
+    $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php
new file mode 100644
index 0000000000..9fa38ab07e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php
@@ -0,0 +1,380 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Functional;
+
+use Composer\Util\Filesystem;
+use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use Drupal\Tests\Component\Scaffold\ScaffoldTestResult;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests Composer Scaffold.
+ *
+ * The purpose of this test file is to exercise all of the different kinds of
+ * scaffold operations: copy, symlinks, skips and so on.
+ *
+ * @group Scaffold
+ */
+class ScaffoldTest extends TestCase {
+  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.
+   *
+   * @var string
+   */
+  protected $fixturesDir;
+
+  /**
+   * The Symfony FileSystem component.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * The Fixtures object.
+   *
+   * @var \Drupal\Tests\Component\Scaffold\Fixtures
+   */
+  protected $fixtures;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->fileSystem = new Filesystem();
+    $this->fixtures = new Fixtures();
+    $this->projectRoot = $this->fixtures->projectRoot();
+    // The directory used for creating composer projects to test can be
+    // configured using the SCAFFOLD_FIXTURE_DIR environment variable. Otherwise
+    // a directory will be created in the system's temporary directory.
+    $this->fixturesDir = getenv('SCAFFOLD_FIXTURE_DIR');
+    if (!$this->fixturesDir) {
+      $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    // Remove any temporary directories et. al. that were created.
+    $this->fixtures->tearDown();
+  }
+
+  /**
+   * Creates the System-Under-Test.
+   *
+   * @param string $fixture_name
+   *   The name of the fixture to use from
+   *   core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+   * @param array $replacements
+   *   Key : value mappings for placeholders to replace in composer.json
+   *   templates.
+   *
+   * @return string
+   *   The path to the created System-Under-Test.
+   */
+  protected function createSut($fixture_name, array $replacements = []) {
+    $sut = $this->fixturesDir . '/' . $fixture_name;
+    // Erase just our sut, to ensure it is clean. Recopy all of the fixtures.
+    $this->fileSystem->remove($sut);
+    $replacements += ['PROJECT_ROOT' => $this->projectRoot];
+    $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+    return $sut;
+  }
+
+  /**
+   * Creates the system-under-test and runs a scaffold operation on it.
+   *
+   * @param string $fixture_name
+   *   The name of the fixture to use from
+   *   core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+   * @param bool $is_link
+   *   Whether to use symlinks for 'replace' operations.
+   * @param bool $relocated_docroot
+   *   Whether the named fixture has a relocated document root.
+   */
+  public function scaffoldSut($fixture_name, $is_link = FALSE, $relocated_docroot = TRUE) {
+    $sut = $this->createSut($fixture_name, ['SYMLINK' => $is_link ? 'true' : 'false']);
+    // Run composer install to get the dependencies we need to test.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+    // Test composer:scaffold.
+    $scaffoldOutput = $this->fixtures->runScaffold($sut);
+
+    // Calculate the docroot directory and assert that our fixture layout
+    // matches what was stipulated in $relocated_docroot. Fail fast if
+    // the caller provided the wrong value.
+    $docroot = $sut;
+    if ($relocated_docroot) {
+      $docroot .= '/docroot';
+      $this->assertFileExists($docroot);
+    }
+    else {
+      $this->assertFileNotExists($sut . '/docroot');
+    }
+
+    return new ScaffoldTestResult($docroot, $scaffoldOutput);
+  }
+
+  /**
+   * Data provider for testScaffoldWithExpectedException.
+   */
+  public function scaffoldExpectedExceptionTestValues() {
+    return [
+      [
+        'drupal-drupal-missing-scaffold-file',
+        'Scaffold file assets/missing-robots-default.txt not found in package fixtures/drupal-drupal-missing-scaffold-file.',
+        TRUE,
+      ],
+
+      [
+        'project-with-empty-scaffold-path',
+        'No scaffold file path given for [web-root]/my-error in package fixtures/project-with-empty-scaffold-path',
+        FALSE,
+      ],
+
+      [
+        'project-with-illegal-dir-scaffold',
+        'Scaffold file assets in package fixtures/project-with-illegal-dir-scaffold is a directory; only files may be scaffolded',
+        FALSE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that scaffold files throw when they have bad values.
+   *
+   * @param string $fixture_name
+   *   The name of the fixture to use from
+   *   core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+   * @param string $expected_exception_message
+   *   The expected exception message.
+   * @param bool $is_link
+   *   Whether or not symlinking should be used.
+   *
+   * @dataProvider scaffoldExpectedExceptionTestValues
+   */
+  public function testScaffoldWithExpectedException($fixture_name, $expected_exception_message, $is_link) {
+    // Test scaffold. Expect an error.
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage($expected_exception_message);
+    $this->scaffoldSut($fixture_name, $is_link);
+  }
+
+  /**
+   * Try to scaffold a project that does not scaffold anything.
+   */
+  public function testEmptyProject() {
+    $fixture_name = 'empty-fixture';
+
+    $result = $this->scaffoldSut($fixture_name, FALSE, FALSE);
+    $this->assertContains('Nothing scaffolded because no packages are allowed in the top-level composer.json file', $result->scaffoldOutput());
+  }
+
+  /**
+   * Try to scaffold a project that allows a project with no scaffold files.
+   */
+  public function testProjectThatScaffoldsEmptyProject() {
+    $fixture_name = 'project-allowing-empty-fixture';
+    $is_link = FALSE;
+    $result = $this->scaffoldSut($fixture_name, FALSE, FALSE);
+    $this->assertContains('The allowed package fixtures/empty-fixture does not provide a file mapping for Composer Scaffold', $result->scaffoldOutput());
+    $this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), FALSE);
+  }
+
+  public function scaffoldOverridingSettingsExcludingHtaccessValues() {
+    return [
+      [
+        'drupal-composer-drupal-project',
+        TRUE,
+        TRUE,
+      ],
+
+      [
+        'drupal-drupal',
+        FALSE,
+        FALSE,
+      ],
+    ];
+  }
+
+  /**
+   * Asserts that the drupal/assets scaffold files correct for sut.
+   *
+   * @param string $fixture_name
+   *   The name of the fixture to use from
+   *   core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+   * @param bool $is_link
+   *   Whether to use symlinks for 'replace' operations.
+   * @param bool $relocated_docroot
+   *   Whether the named fixture has a relocated document root.
+   *
+   * @dataProvider scaffoldOverridingSettingsExcludingHtaccessValues
+   */
+  public function testScaffoldOverridingSettingsExcludingHtaccess($fixture_name, $is_link, $relocated_docroot) {
+    $result = $this->scaffoldSut($fixture_name, $is_link, $relocated_docroot);
+
+    $this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), $is_link);
+    $this->assertDefaultSettingsFromScaffoldOverride($result->docroot(), $is_link);
+    $this->assertHtaccessExcluded($result->docroot());
+  }
+
+  /**
+   * Asserts that the appropriate file was replaced.
+   *
+   * Check the drupal/drupal-based project to confirm that the expected file was
+   * replaced, and that files that were not supposed to be replaced remain
+   * unchanged.
+   */
+  public function testDrupalDrupalFileWasReplaced() {
+    $fixture_name = 'drupal-drupal-test-overwrite';
+    $result = $this->scaffoldSut($fixture_name, FALSE, FALSE);
+
+    $this->assertScaffoldedFile($result->docroot() . '/replace-me.txt', FALSE, 'from assets that replaces file');
+    $this->assertScaffoldedFile($result->docroot() . '/keep-me.txt', FALSE, 'File in drupal-drupal-test-overwrite that is not replaced');
+    $this->assertScaffoldedFile($result->docroot() . '/make-me.txt', FALSE, 'from assets that replaces file');
+    $this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), FALSE);
+    $this->assertScaffoldedFile($result->docroot() . '/robots.txt', FALSE, $fixture_name);
+  }
+
+  /**
+   * Test values for testDrupalDrupalFileWasAppended.
+   */
+  public function scaffoldAppendTestValues() {
+    return array_merge(
+      $this->scaffoldAppendTestValuesToPermute(FALSE),
+      $this->scaffoldAppendTestValuesToPermute(TRUE)
+    );
+  }
+
+  /**
+   * Test values to run both with $is_link FALSE and $is_link TRUE.
+   *
+   * @param bool $is_link
+   *   Whether or not symlinking should be used.
+   */
+  protected function scaffoldAppendTestValuesToPermute($is_link) {
+    return [
+      [
+        'drupal-drupal-test-append',
+        $is_link,
+        '# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+# Test version of robots.txt from drupal/core.
+
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+',
+      ],
+
+      [
+        'drupal-drupal-append-to-append',
+        $is_link,
+        '# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+# Test version of robots.txt from drupal/core.
+
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in profile-with-append composer.json fixture.
+
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.',
+      ],
+    ];
+  }
+
+  /**
+   * Tests a fixture where the robots.txt file is prepended / appended to.
+   *
+   * @param string $fixture_name
+   *   The name of the fixture to use from
+   *   core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+   * @param bool $is_link
+   *   Whether or not symlinking should be used.
+   * @param string $robots_txt_contents
+   *   Regular expression matching expectations for robots.txt.
+   *
+   * @dataProvider scaffoldAppendTestValues
+   */
+  public function testDrupalDrupalFileWasAppended($fixture_name, $is_link, $robots_txt_contents) {
+    $result = $this->scaffoldSut($fixture_name, $is_link, FALSE);
+
+    $this->assertScaffoldedFile($result->docroot() . '/robots.txt', FALSE, $robots_txt_contents);
+    $this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), $is_link);
+  }
+
+  /**
+   * Asserts that the default settings file was overridden by the test.
+   *
+   * @param string $docroot
+   *   The path to the System-under-Test's docroot.
+   * @param bool $is_link
+   *   Whether or not symlinking is used.
+   */
+  protected function assertDefaultSettingsFromScaffoldOverride($docroot, $is_link) {
+    $this->assertScaffoldedFile($docroot . '/sites/default/default.settings.php', $is_link, 'scaffolded from the scaffold-override-fixture');
+  }
+
+  /**
+   * Asserts that the .htaccess file was excluded by the test.
+   *
+   * @param string $docroot
+   *   The path to the System-under-Test's docroot.
+   */
+  protected function assertHtaccessExcluded($docroot) {
+    // Ensure that the .htaccess.txt file was not written, as our
+    // top-level composer.json excludes it from the files to scaffold.
+    $this->assertFileNotExists($docroot . '/.htaccess');
+  }
+
+  /**
+   * Asserts that the scaffold files from drupal/assets are placed as expected.
+   *
+   * This tests that all assets from drupal/assets were scaffolded, save
+   * for .htaccess, robots.txt and default.settings.php, which are scaffolded
+   * in different ways in different tests.
+   *
+   * @param string $docroot
+   *   The path to the System-under-Test's docroot.
+   * @param bool $is_link
+   *   Whether or not symlinking is used.
+   */
+  protected function assertCommonDrupalAssetsWereScaffolded($docroot, $is_link) {
+    // Ensure that the autoload.php file was written.
+    $this->assertFileExists($docroot . '/autoload.php');
+    // Assert other scaffold files are written in the correct locations.
+    $this->assertScaffoldedFile($docroot . '/.csslintrc', $is_link, 'Test version of .csslintrc from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/.editorconfig', $is_link, 'Test version of .editorconfig from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/.eslintignore', $is_link, 'Test version of .eslintignore from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/.eslintrc.json', $is_link, 'Test version of .eslintrc.json from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/.gitattributes', $is_link, 'Test version of .gitattributes from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/.ht.router.php', $is_link, 'Test version of .ht.router.php from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/sites/default/default.services.yml', $is_link, 'Test version of default.services.yml from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/sites/example.settings.local.php', $is_link, 'Test version of example.settings.local.php from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/sites/example.sites.php', $is_link, 'Test version of example.sites.php from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/index.php', $is_link, 'Test version of index.php from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/update.php', $is_link, 'Test version of update.php from drupal/core.');
+    $this->assertScaffoldedFile($docroot . '/web.config', $is_link, 'Test version of web.config from drupal/core.');
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php
new file mode 100644
index 0000000000..e381cd59ce
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use Drupal\Component\Scaffold\Operations\AppendOp;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\AppendOp
+ *
+ * @group Scaffold
+ */
+class AppendOpTest extends TestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcess() {
+    $fixtures = new Fixtures();
+    $destination = $fixtures->destinationPath('[web-root]/robots.txt');
+    $options = ScaffoldOptions::create([]);
+    // Assert that there is no target file before we run our test.
+    $this->assertFileNotExists($destination->fullPath());
+
+    // Create a file.
+    file_put_contents($destination->fullPath(), "# This is a test\n");
+
+    $prepend = $fixtures->sourcePath('drupal-drupal-test-append', 'prepend-to-robots.txt');
+    $append = $fixtures->sourcePath('drupal-drupal-test-append', 'append-to-robots.txt');
+    $sut = new AppendOp($prepend, $append);
+
+    // Test the system under test.
+    $sut->process($destination, $fixtures->io(), $options);
+    // Assert that the target file was created.
+    $this->assertFileExists($destination->fullPath());
+    // Assert the target contained the contents from the correct scaffold files.
+    $contents = trim(file_get_contents($destination->fullPath()));
+    $expected = <<<EOT
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+# This is a test
+
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+EOT;
+    $this->assertEquals(trim($expected), $contents);
+    // Confirm that expected output was written to our io fixture.
+    $output = $fixtures->getOutput();
+    $this->assertContains('Prepend to [web-root]/robots.txt from assets/prepend-to-robots.txt', $output);
+    $this->assertContains('Append to [web-root]/robots.txt from assets/append-to-robots.txt', $output);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php
new file mode 100644
index 0000000000..a45d25635e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use Drupal\Component\Scaffold\Operations\ReplaceOp;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\ReplaceOp
+ *
+ * @group Scaffold
+ */
+class ReplaceOpTest extends TestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcess() {
+    $fixtures = new Fixtures();
+    $destination = $fixtures->destinationPath('[web-root]/robots.txt');
+    $source = $fixtures->sourcePath('drupal-assets-fixture', 'robots.txt');
+    $options = ScaffoldOptions::create([]);
+    $sut = new ReplaceOp($source, TRUE);
+    // Assert that there is no target file before we run our test.
+    $this->assertFileNotExists($destination->fullPath());
+    // Test the system under test.
+    $sut->process($destination, $fixtures->io(), $options);
+    // Assert that the target file was created.
+    $this->assertFileExists($destination->fullPath());
+    // Assert the target contained the contents from the correct scaffold file.
+    $contents = trim(file_get_contents($destination->fullPath()));
+    $this->assertEquals('# Test version of robots.txt from drupal/core.', $contents);
+    // Confirm that expected output was written to our io fixture.
+    $output = $fixtures->getOutput();
+    $this->assertContains('Copy [web-root]/robots.txt from assets/robots.txt', $output);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/ScaffoldFileCollatorTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ScaffoldFileCollatorTest.php
new file mode 100644
index 0000000000..b51f94fc75
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ScaffoldFileCollatorTest.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use PHPUnit\Framework\TestCase;
+use Drupal\Component\Scaffold\ScaffoldFileInfo;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use Drupal\Component\Scaffold\Operations\AppendOp;
+use Drupal\Component\Scaffold\Operations\ConjunctionOp;
+use Drupal\Component\Scaffold\Operations\SkipOp;
+use Drupal\Component\Scaffold\Operations\ScaffoldFileCollator;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\ScaffoldFileCollator
+ *
+ * @group Scaffold
+ */
+class ScaffoldFileCollatorTest extends TestCase {
+
+  /**
+   * @covers ::collateScaffoldFiles
+   */
+  public function testCollateScaffoldFiles() {
+    $fixtures = new Fixtures();
+    $locationReplacements = $fixtures->getLocationReplacements();
+    $scaffold_file_fixtures = [
+      'fixtures/drupal-assets-fixture' => [
+        '[web-root]/index.php' => $fixtures->replaceOp('drupal-assets-fixture', 'index.php'),
+        '[web-root]/.htaccess' => $fixtures->replaceOp('drupal-assets-fixture', '.htaccess'),
+        '[web-root]/robots.txt' => $fixtures->replaceOp('drupal-assets-fixture', 'robots.txt'),
+        '[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-assets-fixture', 'default.services.yml'),
+      ],
+      'fixtures/drupal-profile' => [
+        '[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-profile', 'profile.default.services.yml'),
+      ],
+      'fixtures/drupal-drupal' => [
+        '[web-root]/.htaccess' => new SkipOp(),
+        '[web-root]/robots.txt' => $fixtures->appendOp('drupal-drupal-test-append', 'append-to-robots.txt'),
+      ],
+    ];
+    $sut = new ScaffoldFileCollator($fixtures->io());
+    // Test the system under test.
+    $sut->collateScaffoldFiles($scaffold_file_fixtures, $locationReplacements);
+    $scaffold_list = $this->accessProtected($sut, 'listOfScaffoldFiles');
+    $resolved_file_mappings = $sut->resolvedScaffoldFiles();
+    // Confirm that the keys of the output are the same as the keys of the
+    // input.
+    $this->assertEquals(array_keys($scaffold_file_fixtures), array_keys($resolved_file_mappings));
+    // Also assert that we have the right ScaffoldFileInfo objects in the
+    // destination.
+    $this->assertResolvedToSameOp('fixtures/drupal-assets-fixture', '[web-root]/index.php', $scaffold_file_fixtures, $scaffold_list, $resolved_file_mappings);
+    $this->assertResolvedToSameOp('fixtures/drupal-profile', '[web-root]/sites/default/default.services.yml', $scaffold_file_fixtures, $scaffold_list, $resolved_file_mappings);
+    $this->assertResolvedToSameOp('fixtures/drupal-drupal', '[web-root]/robots.txt', $scaffold_file_fixtures, $scaffold_list, $resolved_file_mappings);
+    // Assert that the files below have been overridden.
+    $this->assertOverridden('fixtures/drupal-assets-fixture', '[web-root]/.htaccess', $scaffold_list, $resolved_file_mappings);
+    $this->assertOverridden('fixtures/drupal-assets-fixture', '[web-root]/robots.txt', $scaffold_list, $resolved_file_mappings);
+  }
+
+  /**
+   * Checks to see if a given file was not overridden.
+   *
+   * The package name in the scaffold list for the provided destination should
+   * match the package name from the specified project.
+   *
+   * @param string $project
+   *   The project to check.
+   * @param string $dest
+   *   The destination to check.
+   * @param array $scaffold_file_fixtures
+   *   The test file mappings keyed by project and destination.
+   * @param \Drupal\Component\Scaffold\ScaffoldFileInfo[] $scaffold_list
+   *   The list of scaffolded files keyed by destination.
+   * @param array $resolved_file_mappings
+   *   The list of resolved file mappings keyed by project and destination.
+   */
+  protected function assertResolvedToSameOp($project, $dest, array $scaffold_file_fixtures, array $scaffold_list, array $resolved_file_mappings) {
+    $resolved_file_info = $resolved_file_mappings[$project][$dest];
+    $this->assertEquals(get_class($resolved_file_info), ScaffoldFileInfo::class);
+    $resolved_scaffold_op = $resolved_file_info->op();
+    // If this is an append op then it will be part of a conjunction op.
+    $expected = get_class($scaffold_file_fixtures[$project][$dest]);
+    if ($expected == AppendOp::class) {
+      $this->assertEquals(ConjunctionOp::class, get_class($resolved_scaffold_op));
+    }
+    else {
+      $this->assertEquals($expected, get_class($resolved_scaffold_op));
+      $this->assertEquals($scaffold_file_fixtures[$project][$dest], $resolved_scaffold_op);
+    }
+    $this->assertEquals($project, $scaffold_list[$dest]->packageName());
+  }
+
+  /**
+   * Checks if a given file was overridden.
+   *
+   * Assert that the file in the scaffold list at the specified destination
+   * comes from a different package than the one in the file info.
+   *
+   * @param string $project
+   *   The project to check.
+   * @param string $dest
+   *   The destination to check.
+   * @param \Drupal\Component\Scaffold\ScaffoldFileInfo[] $scaffold_list
+   *   The list of scaffolded files keyed by destination.
+   * @param array $resolved_file_mappings
+   *   The list of resolved file mappings keyed by project and destination.
+   */
+  protected function assertOverridden($project, $dest, array $scaffold_list, array $resolved_file_mappings) {
+    $resolved_file_info = $resolved_file_mappings[$project][$dest];
+    $this->assertEquals(get_class($resolved_file_info), ScaffoldFileInfo::class);
+    $this->assertNotEquals($project, $scaffold_list[$dest]->packageName());
+  }
+
+  /**
+   * Uses reflection to access a protected field of an object.
+   *
+   * @param mixed $obj
+   *   The object to inspect.
+   * @param string $prop
+   *   The name of the property to access.
+   *
+   * @return mixed
+   *   The value of the requested property.
+   */
+  protected function accessProtected($obj, $prop) {
+    $reflection = new \ReflectionClass($obj);
+    $property = $reflection->getProperty($prop);
+    $property->setAccessible(TRUE);
+    return $property->getValue($obj);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php
new file mode 100644
index 0000000000..27e358e6bf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use Drupal\Component\Scaffold\Operations\SkipOp;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\SkipOp
+ *
+ * @group Scaffold
+ */
+class SkipOpTest extends TestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcess() {
+    $fixtures = new Fixtures();
+    $destination = $fixtures->destinationPath('[web-root]/robots.txt');
+    $options = ScaffoldOptions::create([]);
+    $sut = new SkipOp();
+    // Assert that there is no target file before we run our test.
+    $this->assertFileNotExists($destination->fullPath());
+    // Test the system under test.
+    $sut->process($destination, $fixtures->io(), $options);
+    // Assert that the target file was not created.
+    $this->assertFileNotExists($destination->fullPath());
+    // Confirm that expected output was written to our io fixture.
+    $output = $fixtures->getOutput();
+    $this->assertContains('Skip [web-root]/robots.txt: disabled', $output);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/ScaffoldTestResult.php b/core/tests/Drupal/Tests/Component/Scaffold/ScaffoldTestResult.php
new file mode 100644
index 0000000000..3841cccb69
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/ScaffoldTestResult.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold;
+
+/**
+ * Holds result of a scaffold test.
+ */
+class ScaffoldTestResult {
+
+  protected $docroot;
+  protected $scaffoldOutput;
+
+  /**
+   * Holds the location of the scaffold fixture and the stdout from the test.
+   *
+   * @param string $docroot
+   * @param string $scaffoldOutput
+   */
+  public function __construct($docroot, $scaffoldOutput) {
+    $this->docroot = $docroot;
+    $this->scaffoldOutput = $scaffoldOutput;
+  }
+
+  /**
+   * Returns the location of the docroot from the scaffold test.
+   *
+   * @return string
+   */
+  public function docroot() {
+    return $this->docroot;
+  }
+
+  /**
+   * Returns the standard output from the scaffold test.
+   *
+   * @return string
+   */
+  public function scaffoldOutput() {
+    return $this->scaffoldOutput;
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md
new file mode 100644
index 0000000000..0c8ab5bf80
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md
@@ -0,0 +1,38 @@
+# Fixtures README
+
+These fixtures are automatically copied to a temporary directory during test
+runs. After the test run, the fixtures are automatically deleted.
+
+Set the SCAFFOLD_FIXTURE_DIR environment variable to place the fixtures in a
+specific location rather than a temporary directory. If this is done, then the
+fixtures will not be deleted after the test run. This is useful for ad-hoc
+testing.
+
+Example:
+
+$ SCAFFOLD_FIXTURE_DIR=$HOME/tmp/scaffold-fixtures composer unit
+$ cd $HOME/tmp/scaffold-fixtures
+$ cd drupal-drupal
+$ composer composer:scaffold
+
+Scaffolding files for fixtures/drupal-assets-fixture:
+  - Link [web-root]/.csslintrc from assets/.csslintrc
+  - Link [web-root]/.editorconfig from assets/.editorconfig
+  - Link [web-root]/.eslintignore from assets/.eslintignore
+  - Link [web-root]/.eslintrc.json from assets/.eslintrc.json
+  - Link [web-root]/.gitattributes from assets/.gitattributes
+  - Link [web-root]/.ht.router.php from assets/.ht.router.php
+  - Skip [web-root]/.htaccess: overridden in my/project
+  - Link [web-root]/sites/default/default.services.yml from assets/default.services.yml
+  - Skip [web-root]/sites/default/default.settings.php: overridden in fixtures/scaffold-override-fixture
+  - Link [web-root]/sites/example.settings.local.php from assets/example.settings.local.php
+  - Link [web-root]/sites/example.sites.php from assets/example.sites.php
+  - Link [web-root]/index.php from assets/index.php
+  - Skip [web-root]/robots.txt: overridden in my/project
+  - Link [web-root]/update.php from assets/update.php
+  - Link [web-root]/web.config from assets/web.config
+Scaffolding files for fixtures/scaffold-override-fixture:
+  - Link [web-root]/sites/default/default.settings.php from assets/override-settings.php
+Scaffolding files for my/project:
+  - Skip [web-root]/.htaccess: disabled
+  - Link [web-root]/robots.txt from assets/robots-default.txt
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt
new file mode 100644
index 0000000000..a26bf8912f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..f08fa60383
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl
@@ -0,0 +1,67 @@
+{
+  "name": "fixtures/drupal-drupal",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt
new file mode 100644
index 0000000000..a26bf8912f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..2acbea22f4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl
@@ -0,0 +1,63 @@
+{
+  "name": "fixtures/drupal-drupal",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc
new file mode 100644
index 0000000000..f5bca65208
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc
@@ -0,0 +1 @@
+# Test version of .csslintrc from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig
new file mode 100644
index 0000000000..dcf0c98bbf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig
@@ -0,0 +1 @@
+# Test version of .editorconfig from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore
new file mode 100644
index 0000000000..f0405c03ad
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore
@@ -0,0 +1 @@
+# Test version of .eslintignore from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json
new file mode 100644
index 0000000000..6391fb0c6e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json
@@ -0,0 +1 @@
+// Test version of .eslintrc.json from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes
new file mode 100644
index 0000000000..03436baebc
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes
@@ -0,0 +1 @@
+# Test version of .gitattributes from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php
new file mode 100644
index 0000000000..0cd34ad7ad
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php
@@ -0,0 +1,2 @@
+<?php
+// Test version of .ht.router.php from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.htaccess b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.htaccess
new file mode 100644
index 0000000000..4cfce848ab
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.htaccess
@@ -0,0 +1 @@
+# Test version of .htaccess from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.services.yml b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.services.yml
new file mode 100644
index 0000000000..cc773f3e79
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.services.yml
@@ -0,0 +1,4 @@
+# Test version of default.services.yml from drupal/core.
+# Add a dummy key until YamlPecl can validate an empty YAML file:
+# https://www.drupal.org/project/drupal/issues/3003300
+foo: bar
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.settings.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.settings.php
new file mode 100644
index 0000000000..82b1cb21e1
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of default.settings.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.settings.local.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.settings.local.php
new file mode 100644
index 0000000000..074efa4648
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.settings.local.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of example.settings.local.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.sites.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.sites.php
new file mode 100644
index 0000000000..d0e0e01005
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.sites.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of example.sites.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/index.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/index.php
new file mode 100644
index 0000000000..6dd96fde33
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/index.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of index.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/robots.txt
new file mode 100644
index 0000000000..19d2a6b087
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/robots.txt
@@ -0,0 +1 @@
+# Test version of robots.txt from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/update.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/update.php
new file mode 100644
index 0000000000..3ccf1cb1b0
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/update.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of update.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/web.config b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/web.config
new file mode 100644
index 0000000000..f3344d0a20
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/web.config
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Test version of web.config from drupal/core. -->
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json
new file mode 100644
index 0000000000..cb45ec5dc4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json
@@ -0,0 +1,24 @@
+{
+  "name": "fixtures/drupal-assets-fixture",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/.csslintrc": "assets/.csslintrc",
+        "[web-root]/.editorconfig": "assets/.editorconfig",
+        "[web-root]/.eslintignore": "assets/.eslintignore",
+        "[web-root]/.eslintrc.json": "assets/.eslintrc.json",
+        "[web-root]/.gitattributes": "assets/.gitattributes",
+        "[web-root]/.ht.router.php": "assets/.ht.router.php",
+        "[web-root]/.htaccess": "assets/.htaccess",
+        "[web-root]/sites/default/default.services.yml": "assets/default.services.yml",
+        "[web-root]/sites/default/default.settings.php": "assets/default.settings.php",
+        "[web-root]/sites/example.settings.local.php": "assets/example.settings.local.php",
+        "[web-root]/sites/example.sites.php": "assets/example.sites.php",
+        "[web-root]/index.php": "assets/index.php",
+        "[web-root]/robots.txt": "assets/robots.txt",
+        "[web-root]/update.php": "assets/update.php",
+        "[web-root]/web.config": "assets/web.config"
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore
new file mode 100644
index 0000000000..19982ea3fd
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore
@@ -0,0 +1,2 @@
+composer.lock
+vendor
\ No newline at end of file
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt
new file mode 100644
index 0000000000..c29217ef39
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-composer-drupal-project composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl
new file mode 100644
index 0000000000..207e3baa6c
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl
@@ -0,0 +1,68 @@
+{
+  "name": "fixtures/drupal-composer-drupal-project",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./docroot"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "docroot/core": ["type:drupal-core"],
+      "docroot/modules/contrib/{$name}": ["type:drupal-module"],
+      "docroot/modules/custom/{$name}": ["type:drupal-custom-module"],
+      "docroot/profiles/contrib/{$name}": ["type:drupal-profile"],
+      "docroot/profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "docroot/themes/contrib/{$name}": ["type:drupal-theme"],
+      "docroot/themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "docroot/libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore
new file mode 100644
index 0000000000..c795b054e5
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore
@@ -0,0 +1 @@
+build
\ No newline at end of file
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json
new file mode 100644
index 0000000000..9aa29f55bf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json
@@ -0,0 +1,13 @@
+{
+  "name": "fixtures/drupal-core-fixture",
+  "require": {
+    "fixtures/drupal-assets-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-assets-fixture"
+      ]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/append-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/append-to-robots.txt
new file mode 100644
index 0000000000..5290d32590
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/append-to-robots.txt
@@ -0,0 +1,3 @@
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/prepend-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/prepend-to-robots.txt
new file mode 100644
index 0000000000..8f7550ff5e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/prepend-to-robots.txt
@@ -0,0 +1,3 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/composer.json.tmpl
new file mode 100644
index 0000000000..1919d878c6
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/composer.json.tmpl
@@ -0,0 +1,71 @@
+{
+  "name": "fixtures/drupal-drupal-test-append",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "profile-with-append": {
+      "type": "path",
+      "url": "../profile-with-append",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/profile-with-append": "*",
+    "fixtures/drupal-core-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/profile-with-append"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": {
+          "prepend": "assets/prepend-to-robots.txt",
+          "append": "assets/append-to-robots.txt"
+        }
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl
new file mode 100644
index 0000000000..8d9cf57293
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl
@@ -0,0 +1,68 @@
+{
+  "name": "fixtures/drupal-drupal-missing-scaffold-file",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/missing-robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt
new file mode 100644
index 0000000000..8f05fc3b3a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt
@@ -0,0 +1,3 @@
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt
new file mode 100644
index 0000000000..995c204a6e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt
@@ -0,0 +1,3 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl
new file mode 100644
index 0000000000..9e6c579692
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl
@@ -0,0 +1,62 @@
+{
+  "name": "fixtures/drupal-drupal-test-append",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": {
+          "prepend": "assets/prepend-to-robots.txt",
+          "append": "assets/append-to-robots.txt"
+        }
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt
new file mode 100644
index 0000000000..4e23d0c860
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt
@@ -0,0 +1 @@
+# File from assets that replaces file in web root.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt
new file mode 100644
index 0000000000..28c7646d81
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-overwrite composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl
new file mode 100644
index 0000000000..d16205c612
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl
@@ -0,0 +1,80 @@
+{
+  "name": "fixtures/drupal-drupal-test-overwrite",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt",
+        "make-me.txt": {
+          "path": "assets/replacement.txt",
+          "overwrite": false
+        },
+        "keep-me.txt": {
+          "path": "assets/replacement.txt",
+          "overwrite": false
+        },
+        "replace-me.txt": {
+          "path": "assets/replacement.txt",
+          "overwrite": true
+        }
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt
new file mode 100644
index 0000000000..772a59531a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt
@@ -0,0 +1 @@
+# File in drupal-drupal-test-overwrite that is not replaced by a scaffold file.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt
new file mode 100644
index 0000000000..4147b02214
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt
@@ -0,0 +1 @@
+# File in drupal-drupal-test-overwrite that is replaced by a scaffold file.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt
new file mode 100644
index 0000000000..6eb30e86aa
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl
new file mode 100644
index 0000000000..6277bd8fb1
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl
@@ -0,0 +1,71 @@
+{
+  "name": "fixtures/drupal-drupal",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "gitignore": false,
+      "overwrite": true,
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": {
+          "mode": "replace",
+          "path": "assets/robots-default.txt",
+          "overwrite": true
+        }
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml
new file mode 100644
index 0000000000..2a35c02dba
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml
@@ -0,0 +1,4 @@
+# default.services.yml fixture scaffolded from "file-mappings" in drupal-project composer.json fixture.
+# Add a dummy key until YamlPecl can validate an empty YAML file:
+# https://www.drupal.org/project/drupal/issues/3003300
+foo: bar
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl
new file mode 100644
index 0000000000..b3e4b76640
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl
@@ -0,0 +1,10 @@
+{
+  "name": "fixtures/drupal-profile",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/.htaccess": false
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json
new file mode 100644
index 0000000000..66e96bf38b
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json
@@ -0,0 +1,13 @@
+{
+  "name": "fixtures/empty-fixture-allowing-core",
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json
new file mode 100644
index 0000000000..7eb36a74cb
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json
@@ -0,0 +1,3 @@
+{
+  "name": "fixtures/empty-fixture"
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json
new file mode 100644
index 0000000000..de4226972a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json
@@ -0,0 +1,14 @@
+{
+    "packages": {
+        "fixtures/drupal-drupal": {
+            "dev-master": {
+                "name": "fixtures/drupal-drupal",
+                "version": "1.0.0",
+                "dist": {
+                    "url": "./drupal-drupal",
+                    "type": "path"
+                }
+            }
+        }
+    }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/assets/append-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/assets/append-to-robots.txt
new file mode 100644
index 0000000000..ab2eb43a74
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/assets/append-to-robots.txt
@@ -0,0 +1,3 @@
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in profile-with-append composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/composer.json.tmpl
new file mode 100644
index 0000000000..d67135fcac
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/composer.json.tmpl
@@ -0,0 +1,12 @@
+{
+  "name": "fixtures/profile-with-append",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": {
+          "append": "assets/append-to-robots.txt"
+        }
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..254492a6e9
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl
@@ -0,0 +1,77 @@
+{
+  "name": "fixtures/project-allowing-empty-fixture",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "empty-fixture": {
+      "type": "path",
+      "url": "../empty-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/empty-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/empty-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "gitignore": false,
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json
new file mode 100644
index 0000000000..e74df96ff0
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json
@@ -0,0 +1,15 @@
+{
+  "name": "fixtures/project-with-empty-scaffold-path",
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./"
+      },
+      "file-mapping": {
+        "[web-root]/my-error": {
+            "path": ""
+        }
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json
new file mode 100644
index 0000000000..5cebfd4b57
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json
@@ -0,0 +1,15 @@
+{
+  "name": "fixtures/project-with-illegal-dir-scaffold",
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./"
+      },
+      "file-mapping": {
+        "[web-root]/assets": {
+            "path": "assets"
+        }
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php
new file mode 100644
index 0000000000..064ed7e3f5
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * A settings.php fixture file scaffolded from the scaffold-override-fixture.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/composer.json
new file mode 100644
index 0000000000..977af8a76e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/composer.json
@@ -0,0 +1,10 @@
+{
+  "name": "fixtures/scaffold-override-fixture",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/sites/default/default.settings.php": "assets/override-settings.php"
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scripts/disable-git-bin/git b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scripts/disable-git-bin/git
new file mode 100755
index 0000000000..36fa746230
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scripts/disable-git-bin/git
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 127
