diff --git a/core/modules/auto_updates/auto_updates.info.yml b/core/modules/auto_updates/auto_updates.info.yml
index 1d396122f25e8c8a922acb70fe5b2738722e7c31..8f3b0999b2b0e01313899dcd0fbfaa3352840d66 100644
--- a/core/modules/auto_updates/auto_updates.info.yml
+++ b/core/modules/auto_updates/auto_updates.info.yml
@@ -1,6 +1,6 @@
 name: 'Automatic Updates'
 type: module
-description: 'Experimental module to develop automatic updates. Currently the module provides checks for update readiness but does not yet provide update functionality.'
+description: 'Automatically updates Drupal core.'
 package: Core
 version: VERSION
 lifecycle: experimental
diff --git a/core/modules/auto_updates/auto_updates.install b/core/modules/auto_updates/auto_updates.install
index 1923c8c5297f1407493e9b5c74a6796585904594..b7ab9425fef6dd3aeef26b679271c95a0773f69d 100644
--- a/core/modules/auto_updates/auto_updates.install
+++ b/core/modules/auto_updates/auto_updates.install
@@ -12,6 +12,7 @@
  */
 function auto_updates_requirements($phase) {
   if ($phase === 'runtime') {
+    // Check that site is ready to perform automatic updates.
     /** @var \Drupal\auto_updates\Validation\ReadinessRequirements $readiness_requirement */
     $readiness_requirement = \Drupal::classResolver(ReadinessRequirements::class);
     return $readiness_requirement->getRequirements();
diff --git a/core/modules/auto_updates/auto_updates.module b/core/modules/auto_updates/auto_updates.module
index 8ebaf73a8d4afe0881bca104c4fb943cc866e7ec..e4aa6851bdd5ee7165a70a21019712e7503ae4b5 100644
--- a/core/modules/auto_updates/auto_updates.module
+++ b/core/modules/auto_updates/auto_updates.module
@@ -47,6 +47,7 @@ function auto_updates_page_top() {
     'auto_updates.report_update',
     'auto_updates.module_update',
   ];
+  // @see auto_updates_module_implements_alter()
   $route_name = \Drupal::routeMatch()->getRouteName();
   if (!in_array($route_name, $skip_routes, TRUE) && function_exists('update_page_top')) {
     update_page_top();
@@ -63,8 +64,7 @@ function auto_updates_module_implements_alter(&$implementations, $hook) {
     // Remove hook_page_top() implementation from the Update module. This '
     // implementation displays error messages about security releases. We call
     // this implementation in our own auto_updates_page_top() except on our
-    // own routes to avoid stale messages about the security releases after an
-    // update.
+    // own routes to avoid these messages while an update is in progress.
     unset($implementations['update']);
   }
 }
@@ -114,7 +114,9 @@ function auto_updates_modules_uninstalled() {
  */
 function auto_updates_form_update_manager_update_form_alter(&$form, FormStateInterface $form_state, $form_id) {
   // Remove current message that core updates are not supported with a link to
-  // use this modules form.
+  // use this module's form. The local task to 'update_manager_update_form' is
+  // replaced by our own from but this original form would still accessible via
+  // by its original URL.
   if (isset($form['manual_updates']['#rows']['drupal']['data']['title'])) {
     $current_route = \Drupal::routeMatch()->getRouteName();
     if ($current_route === 'update.module_update') {
@@ -141,6 +143,8 @@ function auto_updates_form_update_settings_alter(array &$form, FormStateInterfac
   $drupal_project = $recommender->getProjectInfo();
   $version = ExtensionVersion::createFromVersionString($drupal_project['existing_version']);
   $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion();
+  // @todo In https://www.drupal.org/node/2998285 use the update XML to
+  //   determine when the installed of core will become unsupported.
   $supported_until_version = $version->getMajorVersion() . '.'
       . ((int) $version->getMinorVersion() + ProjectSecurityData::CORE_MINORS_WITH_SECURITY_COVERAGE)
       . '.0';
diff --git a/core/modules/auto_updates/auto_updates.routing.yml b/core/modules/auto_updates/auto_updates.routing.yml
index e3a5d1bb9d7c2d943b95106a8d530823f62f1662..77fcaf6d764e976c945ad0f51abdfcbdb929716f 100644
--- a/core/modules/auto_updates/auto_updates.routing.yml
+++ b/core/modules/auto_updates/auto_updates.routing.yml
@@ -13,7 +13,7 @@ auto_updates.confirmation_page:
   requirements:
     _permission: 'administer software updates'
     _access_update_manager: 'TRUE'
-# Links to our updater form appear in two different sets of local tasks. To ensure the breadcrumbs and paths are
+# Links to our updater form appear in three different sets of local tasks. To ensure the breadcrumbs and paths are
 # consistent with the other local tasks in each set, we need two separate routes to the same form.
 auto_updates.report_update:
   path: '/admin/reports/updates/automatic-update'
diff --git a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
index a1d2bfd9b42fd91275f18982400e7f5faa681a4a..bb57325de21e0dead98566fbc0572d406945a67f 100644
--- a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
+++ b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
@@ -53,7 +53,7 @@ public function getPackageVersions(): array {
   }
 
   /**
-   * {@inheritdoc}
+   * Adds warning information to the event.
    */
   public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL) {
     $this->results[] = ValidationResult::createWarning($messages, $summary);
diff --git a/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
index b8a6dbe9317365e5bbbfdaf698967e7d24d2d8ba..0d4838f266a52a72a2774d2c4d7b5d44064dda4f 100644
--- a/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
+++ b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
@@ -3,7 +3,7 @@
 namespace Drupal\auto_updates\Validator;
 
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
-use Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface;
+use Drupal\package_manager\Validator\PreOperationStageValidatorInterface;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
@@ -18,14 +18,14 @@ class PackageManagerReadinessCheck implements EventSubscriberInterface {
   /**
    * The validator to run.
    *
-   * @var \Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface
+   * @var \Drupal\package_manager\Validator\PreOperationStageValidatorInterface
    */
   protected $validator;
 
   /**
    * Constructs a PackageManagerReadinessCheck object.
    *
-   * @param \Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface $validator
+   * @param \Drupal\package_manager\Validator\PreOperationStageValidatorInterface $validator
    *   The Package Manager validator to run during readiness checking.
    */
   public function __construct(PreOperationStageValidatorInterface $validator) {
diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
index 8ef429977846dc114388eb587992dff0d4939833..ff95f788ba7e73d2e8edf922cc9723afd0b5a132 100644
--- a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
+++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
@@ -32,17 +32,6 @@ protected function tearDown(): void {
   protected function createTestProject(string $template): void {
     parent::createTestProject($template);
 
-    // BEGIN: DELETE FROM CORE MERGE REQUEST
-    // Install Automatic Updates into the test project and ensure it wasn't
-    // symlinked.
-    if (__NAMESPACE__ === 'Drupal\Tests\automatic_updates\Build') {
-      $dir = 'project';
-      $this->runComposer('composer config repo.automatic_updates path ' . __DIR__ . '/../../..', $dir);
-      $this->runComposer('composer require --no-update "drupal/automatic_updates:@dev"', $dir);
-      $output = $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer update --with-all-dependencies', $dir);
-      $this->assertStringNotContainsString('Symlinking', $output);
-    }
-    // END: DELETE FROM CORE MERGE REQUEST
     // Install Drupal. Always allow test modules to be installed in the UI and,
     // for easier debugging, always display errors in their dubious glory.
     $this->installQuickStart('minimal');
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
index 553570872117ff715bf8c95d2989007fe43c55f7..af4287913ca557e55f6f0f76d0da4d58e5d31306 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
@@ -4,7 +4,7 @@
 
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface;
+use Drupal\package_manager\Validator\PreOperationStageValidatorInterface;
 use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
 use Prophecy\Argument;
 
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
index c84e7c5e7a6657fe02ee46942c8e0b83c9d69cbe..8ad7063c8489fc604fcf5c84db369fe55dc42b70 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -118,9 +118,15 @@ public function testEventConsumesExceptionResults(): void {
     copy("$fixture/composer.lock", 'public://composer.lock');
 
     $event_dispatcher = $this->container->get('event_dispatcher');
-    // Disable the disk space validator, since it doesn't work with vfsStream.
-    $disk_space_validator = $this->container->get('package_manager.validator.disk_space');
-    $event_dispatcher->removeSubscriber($disk_space_validator);
+    // Disable the disk space validator, since it doesn't work with vfsStream,
+    // and the excluded paths subscriber, since it won't deal with this tiny
+    // virtual file system correctly.
+    $disable_subscribers = array_map([$this->container, 'get'], [
+      'package_manager.validator.disk_space',
+      'package_manager.excluded_paths_subscriber',
+    ]);
+    array_walk($disable_subscribers, [$event_dispatcher, 'removeSubscriber']);
+
     // Just before the staged changes are applied, delete the composer.json file
     // to trigger an error. This uses the highest possible priority to guarantee
     // it runs before any other subscribers.
diff --git a/core/modules/package_manager/package_manager.info.yml b/core/modules/package_manager/package_manager.info.yml
index 9af8cfa9ad5128fda3770f5a28356f8038f5facb..dd204c7f0f32bfcb93bdfe99ae12628b7f94439d 100644
--- a/core/modules/package_manager/package_manager.info.yml
+++ b/core/modules/package_manager/package_manager.info.yml
@@ -1,6 +1,6 @@
 name: 'Package Manager'
 type: module
-description: 'API module providing functionality for staging package updates.'
+description: 'API module providing functionality for staging package installs and updates with Composer.'
 package: Core
 version: VERSION
 lifecycle: experimental
diff --git a/core/modules/package_manager/package_manager.install b/core/modules/package_manager/package_manager.install
deleted file mode 100644
index 0793ddea7a27e2cef93be3aae26f01de6d642bec..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/package_manager.install
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-
-/**
- * @file
- * Contains install and update functions for Package Manager.
- */
-
-/**
- * Implements hook_requirements().
- */
-function package_manager_requirements() {
-  if (!class_exists('PhpTuf\ComposerStager\Domain\Beginner')) {
-    return [
-      'package_manager' => [
-        'description' => t('External dependencies for Package Manager are not available. Composer must be used to download the module with dependencies.'),
-        'severity' => REQUIREMENT_ERROR,
-      ],
-    ];
-  }
-}
diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml
index f5e04442d0d43f9b48c55d34e6e6c0f87c5245f3..7480aaae798c0165513bc8d5cbfd78d6181ea9e1 100644
--- a/core/modules/package_manager/package_manager.services.yml
+++ b/core/modules/package_manager/package_manager.services.yml
@@ -77,21 +77,21 @@ services:
 
   # Validators.
   package_manager.validator.composer_executable:
-    class: Drupal\package_manager\EventSubscriber\ComposerExecutableValidator
+    class: Drupal\package_manager\Validator\ComposerExecutableValidator
     arguments:
       - '@package_manager.composer_runner'
       - '@string_translation'
     tags:
       - { name: event_subscriber }
   package_manager.validator.disk_space:
-    class: Drupal\package_manager\EventSubscriber\DiskSpaceValidator
+    class: Drupal\package_manager\Validator\DiskSpaceValidator
     arguments:
       - '@package_manager.path_locator'
       - '@string_translation'
     tags:
       - { name: event_subscriber }
   package_manager.validator.pending_updates:
-    class: Drupal\package_manager\EventSubscriber\PendingUpdatesValidator
+    class: Drupal\package_manager\Validator\PendingUpdatesValidator
     arguments:
       - '%app.root%'
       - '@update.post_update_registry'
@@ -99,7 +99,7 @@ services:
     tags:
       - { name: event_subscriber }
   package_manager.validator.lock_file:
-    class: Drupal\package_manager\EventSubscriber\LockFileValidator
+    class: Drupal\package_manager\Validator\LockFileValidator
     arguments:
       - '@state'
       - '@package_manager.path_locator'
@@ -107,7 +107,7 @@ services:
     tags:
       - { name: event_subscriber }
   package_manager.validator.file_system:
-    class: Drupal\package_manager\EventSubscriber\WritableFileSystemValidator
+    class: Drupal\package_manager\Validator\WritableFileSystemValidator
     arguments:
       - '@package_manager.path_locator'
       - '%app.root%'
@@ -115,7 +115,7 @@ services:
     tags:
       - { name: event_subscriber }
   package_manager.validator.composer_settings:
-    class: Drupal\package_manager\EventSubscriber\ComposerSettingsValidator
+    class: Drupal\package_manager\Validator\ComposerSettingsValidator
     arguments:
       - '@string_translation'
     tags:
diff --git a/core/modules/package_manager/src/ComposerUtility.php b/core/modules/package_manager/src/ComposerUtility.php
index 6a111b74a911b77919483aa09bf9673d5d91030d..cfab9e40873d4b93f71d8072f4379ee177547266 100644
--- a/core/modules/package_manager/src/ComposerUtility.php
+++ b/core/modules/package_manager/src/ComposerUtility.php
@@ -48,7 +48,7 @@ public function getComposer(): Composer {
   }
 
   /**
-   * Creates a utility object using the files in a given directory.
+   * Creates an instance of this class using the files in a given directory.
    *
    * @param string $dir
    *   The directory that contains composer.json and composer.lock.
@@ -111,7 +111,8 @@ protected static function getCorePackageList(): array {
    * @return string[]
    *   The names of the required core packages.
    *
-   * @todo Make this return a keyed array of packages, not just names.
+   * @todo Make this return a keyed array of packages, not just names in
+   *   https://www.drupal.org/i/3258059.
    */
   public function getCorePackageNames(): array {
     $core_packages = array_intersect(
@@ -135,7 +136,8 @@ public function getCorePackageNames(): array {
    * @return string[]
    *   The names of the core packages in the dev requirements.
    *
-   * @todo Make this return a keyed array of packages, not just names.
+   * @todo Make this return a keyed array of packages, not just names in
+   *   https://www.drupal.org/i/3258059.
    */
   public function getCoreDevPackageNames(): array {
     $dev_packages = $this->composer->getPackage()->getDevRequires();
diff --git a/core/modules/package_manager/src/Event/PostDestroyEvent.php b/core/modules/package_manager/src/Event/PostDestroyEvent.php
index 3388a988e126da919dfbc598946910f38b86cbbe..283bf69e347b1cbc3a2e9631dfec8524c6f1c910 100644
--- a/core/modules/package_manager/src/Event/PostDestroyEvent.php
+++ b/core/modules/package_manager/src/Event/PostDestroyEvent.php
@@ -4,6 +4,11 @@
 
 /**
  * Event fired after the staging area is destroyed.
+ *
+ * If the stage is being force destroyed, ::getStage() may return an object of a
+ * different class than the one that originally created the staging area.
+ *
+ * @see \Drupal\package_manager\Stage::destroy()
  */
 class PostDestroyEvent extends StageEvent {
 }
diff --git a/core/modules/package_manager/src/Event/PostRequireEvent.php b/core/modules/package_manager/src/Event/PostRequireEvent.php
index 55b4184dd3d96daaeeba55317c227fd0bbfb089c..c35076f5cf905eee0a28921f5013ddf33b764a51 100644
--- a/core/modules/package_manager/src/Event/PostRequireEvent.php
+++ b/core/modules/package_manager/src/Event/PostRequireEvent.php
@@ -3,7 +3,7 @@
 namespace Drupal\package_manager\Event;
 
 /**
- * Event fired after packages are added to the staging area.
+ * Event fired after packages are updated to the staging area.
  */
 class PostRequireEvent extends StageEvent {
 }
diff --git a/core/modules/package_manager/src/Event/PreDestroyEvent.php b/core/modules/package_manager/src/Event/PreDestroyEvent.php
index d4918f0b8b75c96a1b62a141b029c086996a3f54..3a876312a24a6d503f0ec98a68c23f2c08d9e279 100644
--- a/core/modules/package_manager/src/Event/PreDestroyEvent.php
+++ b/core/modules/package_manager/src/Event/PreDestroyEvent.php
@@ -4,6 +4,11 @@
 
 /**
  * Event fired before the staging area is destroyed.
+ *
+ * If the stage is being force destroyed, ::getStage() may return an object of a
+ * different class than the one that originally created the staging area.
+ *
+ * @see \Drupal\package_manager\Stage::destroy()
  */
 class PreDestroyEvent extends PreOperationStageEvent {
 }
diff --git a/core/modules/package_manager/src/Event/PreOperationStageEvent.php b/core/modules/package_manager/src/Event/PreOperationStageEvent.php
index ee826f3ac35cdfe6f3a987112786d16a666b7ff9..832677a82e8a3454c328e1ad8103622fff9c5ede 100644
--- a/core/modules/package_manager/src/Event/PreOperationStageEvent.php
+++ b/core/modules/package_manager/src/Event/PreOperationStageEvent.php
@@ -11,7 +11,7 @@
 abstract class PreOperationStageEvent extends StageEvent {
 
   /**
-   * {@inheritdoc}
+   * Adds error information to the event.
    */
   public function addError(array $messages, ?TranslatableMarkup $summary = NULL) {
     $this->results[] = ValidationResult::createError($messages, $summary);
diff --git a/core/modules/package_manager/src/Event/PreRequireEvent.php b/core/modules/package_manager/src/Event/PreRequireEvent.php
index 355bb5047fb89719c0b0711db51d675be0368387..bcb9bfc6337e449fd180a9a8cc027acc0cb500fe 100644
--- a/core/modules/package_manager/src/Event/PreRequireEvent.php
+++ b/core/modules/package_manager/src/Event/PreRequireEvent.php
@@ -3,7 +3,7 @@
 namespace Drupal\package_manager\Event;
 
 /**
- * Event fired before packages are added to the staging area.
+ * Event fired before packages are updated to the staging area.
  */
 class PreRequireEvent extends PreOperationStageEvent {
 }
diff --git a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php b/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
index b65ff7a70921bc61e90f3a927e9e471a01b6ba82..a54a4e2816e77c549c27aca46f6a574600507465 100644
--- a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
+++ b/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
@@ -11,6 +11,7 @@
 use Drupal\package_manager\PathLocator;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Finder\Finder;
 
 /**
  * Defines an event subscriber to exclude certain paths from staging areas.
@@ -180,6 +181,20 @@ public function ignoreCommonPaths(StageEvent $event): void {
       $project[] = $options['database'] . '-wal';
     }
 
+    // Find all .git directories in the project and exclude them. We cannot do
+    // this with FileSystemInterface::scanDirectory() because it unconditionally
+    // excludes anything starting with a dot.
+    $finder = Finder::create()
+      ->in($this->pathLocator->getProjectRoot())
+      ->directories()
+      ->name('.git')
+      ->ignoreVCS(FALSE)
+      ->ignoreDotFiles(FALSE);
+
+    foreach ($finder as $git_directory) {
+      $project[] = $git_directory->getPathname();
+    }
+
     $this->excludeInWebRoot($event, $web);
     $this->excludeInProjectRoot($event, $project);
   }
diff --git a/core/modules/package_manager/src/FileSyncerFactory.php b/core/modules/package_manager/src/FileSyncerFactory.php
index ee96aa1e579cc07a93bbec1eee12a9aa8679c072..f93e31bfecabdb9d5a244f53590a32a68efb98ab 100644
--- a/core/modules/package_manager/src/FileSyncerFactory.php
+++ b/core/modules/package_manager/src/FileSyncerFactory.php
@@ -9,7 +9,7 @@
 use Symfony\Component\Process\ExecutableFinder;
 
 /**
- * A file syncer factory which returns file syncers according to configuration.
+ * A file syncer factory which creates a file syncer according to configuration.
  */
 class FileSyncerFactory implements FileSyncerFactoryInterface {
 
diff --git a/core/modules/package_manager/src/ProcessFactory.php b/core/modules/package_manager/src/ProcessFactory.php
index 8a1ca6583a3be2350d2ba5e53162e758ed512df8..4eb5195cbf1659893a3dc9d2e7bccd0dd47e7640 100644
--- a/core/modules/package_manager/src/ProcessFactory.php
+++ b/core/modules/package_manager/src/ProcessFactory.php
@@ -10,8 +10,6 @@
 
 /**
  * Defines a process factory which sets the COMPOSER_HOME environment variable.
- *
- * @todo Figure out how to do this in composer_stager.
  */
 final class ProcessFactory implements ProcessFactoryInterface {
 
diff --git a/core/modules/package_manager/src/Stage.php b/core/modules/package_manager/src/Stage.php
index 4432089cd00f7d45e4ebe0271a7d1beee31bcedd..6d4678383085849e26e49e7c0fb4d27c2beb4545 100644
--- a/core/modules/package_manager/src/Stage.php
+++ b/core/modules/package_manager/src/Stage.php
@@ -61,21 +61,21 @@ class Stage {
   protected $pathLocator;
 
   /**
-   * The beginner service from Composer Stager.
+   * The beginner service.
    *
    * @var \PhpTuf\ComposerStager\Domain\BeginnerInterface
    */
   protected $beginner;
 
   /**
-   * The stager service from Composer Stager.
+   * The stager service.
    *
    * @var \PhpTuf\ComposerStager\Domain\StagerInterface
    */
   protected $stager;
 
   /**
-   * The committer service from Composer Stager.
+   * The committer service.
    *
    * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
    */
@@ -117,11 +117,11 @@ class Stage {
    * @param \Drupal\package_manager\PathLocator $path_locator
    *   The path locator service.
    * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner
-   *   The beginner service from Composer Stager.
+   *   The beginner service.
    * @param \PhpTuf\ComposerStager\Domain\StagerInterface $stager
-   *   The stager service from Composer Stager.
+   *   The stager service.
    * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer
-   *   The committer service from Composer Stager.
+   *   The committer service.
    * @param \Drupal\Core\File\FileSystemInterface $file_system
    *   The file system service.
    * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
@@ -199,6 +199,9 @@ protected function setMetadata(string $key, $data): void {
    *   performing other operations on it. Calling code should store this ID for
    *   as long as the stage needs to exist.
    *
+   * @throws \Drupal\package_manager\Exception\StageException
+   *   Thrown if a staging area already exists.
+   *
    * @see ::claim()
    */
   public function create(): string {
@@ -246,6 +249,8 @@ public function require(array $constraints, bool $dev = FALSE): void {
     $this->dispatch(new PreRequireEvent($this));
     $dir = $this->getStageDirectory();
     $this->stager->stage($command, $dir);
+    // @todo Limit the update command to only packages in $constraints in
+    //   https://www.drupal.org/i/3257849.
     $this->stager->stage(['update', '--with-all-dependencies'], $dir);
     $this->dispatch(new PostRequireEvent($this));
   }
@@ -285,6 +290,9 @@ public function destroy(bool $force = FALSE): void {
     // Delete all directories in parent staging directory.
     $parent_stage_dir = static::getStagingRoot();
     if (is_dir($parent_stage_dir)) {
+      // @todo Ensure that even if attempting to delete this directory throws an
+      //   exception the stage is still marked as available in
+      //   https://www.drupal.org/i/3258048.
       $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void {
         $this->fileSystem->chmod($path, 0777);
       });
@@ -324,6 +332,7 @@ protected function dispatch(StageEvent $event): void {
       }
     }
     catch (\Throwable $error) {
+      // @todo Simplify exception handling in https://www.drupal.org/i/3258056.
       // If we are not going to be able to create the staging area, mark it as
       // available.
       // @see ::create()
@@ -407,12 +416,16 @@ final public function claim(string $unique_id): self {
   }
 
   /**
-   * Ensures that the current user or session owns the staging area.
+   * Validates the ownership of staging area.
+   *
+   * The stage is considered under valid ownership if it was created by current
+   * user or session, using the current class.
    *
    * @throws \LogicException
    *   If ::claim() has not been previously called.
    * @throws \Drupal\package_manager\Exception\StageOwnershipException
-   *   If the current user or session does not own the staging area.
+   *   If the current user or session does not own the staging area, or it was
+   *   created by a different class.
    */
   final protected function checkOwnership(): void {
     if (empty($this->lock)) {
diff --git a/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
similarity index 95%
rename from core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php
rename to core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
index f91e852930321300c2dd5b4067736165ff3dafc1..db516e5089a0b643015e0fbd4adb68a6d8cd8258 100644
--- a/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php
+++ b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\package_manager\EventSubscriber;
+namespace Drupal\package_manager\Validator;
 
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
@@ -12,7 +12,7 @@
 use PhpTuf\ComposerStager\Exception\ExceptionInterface;
 
 /**
- * Validates that the Composer executable can be found in the correct version.
+ * Validates the Composer executable is the correct version.
  */
 class ComposerExecutableValidator implements PreOperationStageValidatorInterface, OutputCallbackInterface {
 
diff --git a/core/modules/package_manager/src/EventSubscriber/ComposerSettingsValidator.php b/core/modules/package_manager/src/Validator/ComposerSettingsValidator.php
similarity index 96%
rename from core/modules/package_manager/src/EventSubscriber/ComposerSettingsValidator.php
rename to core/modules/package_manager/src/Validator/ComposerSettingsValidator.php
index 64c7249564e83520e06f7cc857311859ebaf640d..0ad1c7c254f7b92923b39d74b8c85567e351d3ca 100644
--- a/core/modules/package_manager/src/EventSubscriber/ComposerSettingsValidator.php
+++ b/core/modules/package_manager/src/Validator/ComposerSettingsValidator.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\package_manager\EventSubscriber;
+namespace Drupal\package_manager\Validator;
 
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
diff --git a/core/modules/package_manager/src/EventSubscriber/DiskSpaceValidator.php b/core/modules/package_manager/src/Validator/DiskSpaceValidator.php
similarity index 95%
rename from core/modules/package_manager/src/EventSubscriber/DiskSpaceValidator.php
rename to core/modules/package_manager/src/Validator/DiskSpaceValidator.php
index c54a653052092cb6f5f6de96292ac8178fea9a0b..7a0a97dc4e7f60703fbb64226a3459541b448e18 100644
--- a/core/modules/package_manager/src/EventSubscriber/DiskSpaceValidator.php
+++ b/core/modules/package_manager/src/Validator/DiskSpaceValidator.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\package_manager\EventSubscriber;
+namespace Drupal\package_manager\Validator;
 
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
@@ -11,7 +11,7 @@
 use Drupal\package_manager\PathLocator;
 
 /**
- * Validates that there is enough free disk space to do automatic updates.
+ * Validates that there is enough free disk space to do staging operations.
  */
 class DiskSpaceValidator implements PreOperationStageValidatorInterface {
 
@@ -103,7 +103,8 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
     $vendor_path = $this->pathLocator->getVendorDirectory();
     $messages = [];
 
-    // @todo Make this configurable.
+    // @todo Make this configurable or set to a different value in
+    //   https://www.drupal.org/i/3166416.
     $minimum_mb = 1024;
     $minimum_bytes = Bytes::toNumber($minimum_mb . 'M');
 
diff --git a/core/modules/package_manager/src/EventSubscriber/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php
similarity index 96%
rename from core/modules/package_manager/src/EventSubscriber/LockFileValidator.php
rename to core/modules/package_manager/src/Validator/LockFileValidator.php
index dab3d8d298bbff2e71830ca55218b8cff4bfad8a..15a3a977d5dafcc716ae6218929e3a8a1b75b945 100644
--- a/core/modules/package_manager/src/EventSubscriber/LockFileValidator.php
+++ b/core/modules/package_manager/src/Validator/LockFileValidator.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\package_manager\EventSubscriber;
+namespace Drupal\package_manager\Validator;
 
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
@@ -109,7 +109,7 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
     }
 
     // If we have both hashes, ensure they match.
-    if ($hash && $stored_hash && hash_equals($stored_hash, $hash) == FALSE) {
+    if ($hash && $stored_hash && !hash_equals($stored_hash, $hash)) {
       $error = $this->t('Stored lock file hash does not match the active lock file.');
     }
 
diff --git a/core/modules/package_manager/src/EventSubscriber/PendingUpdatesValidator.php b/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php
similarity index 97%
rename from core/modules/package_manager/src/EventSubscriber/PendingUpdatesValidator.php
rename to core/modules/package_manager/src/Validator/PendingUpdatesValidator.php
index 53cbc7420633dc26bc0485072ae11b0ede02ce77..16062a98a6198d7ab4d2e4b0c94cef623671fc13 100644
--- a/core/modules/package_manager/src/EventSubscriber/PendingUpdatesValidator.php
+++ b/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\package_manager\EventSubscriber;
+namespace Drupal\package_manager\Validator;
 
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
diff --git a/core/modules/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php b/core/modules/package_manager/src/Validator/PreOperationStageValidatorInterface.php
similarity index 91%
rename from core/modules/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php
rename to core/modules/package_manager/src/Validator/PreOperationStageValidatorInterface.php
index a48ff5127f42a5e5ce0c5af75b4c4e4319479013..a30bfbcda53117347a12b55bde192303868b734c 100644
--- a/core/modules/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php
+++ b/core/modules/package_manager/src/Validator/PreOperationStageValidatorInterface.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\package_manager\EventSubscriber;
+namespace Drupal\package_manager\Validator;
 
 use Drupal\package_manager\Event\PreOperationStageEvent;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
diff --git a/core/modules/package_manager/src/EventSubscriber/WritableFileSystemValidator.php b/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php
similarity index 97%
rename from core/modules/package_manager/src/EventSubscriber/WritableFileSystemValidator.php
rename to core/modules/package_manager/src/Validator/WritableFileSystemValidator.php
index 95bc5d1104deac380383c2c28d996d6f928c1320..24ccc241867fa46634e5ea9c089124b57907f208 100644
--- a/core/modules/package_manager/src/EventSubscriber/WritableFileSystemValidator.php
+++ b/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\package_manager\EventSubscriber;
+namespace Drupal\package_manager\Validator;
 
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/composer.json
deleted file mode 100644
index 0967ef424bce6791893e9a57bb952f80fd536e93..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/private/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/private/ignore.txt
deleted file mode 100644
index 08874eba8bb924527069b41e1195da4b6b69d1dd..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/private/ignore.txt
+++ /dev/null
@@ -1 +0,0 @@
-This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml
deleted file mode 100644
index cbc4434e8f2bc888569a704746bf41606174d259..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-# This file should never be staged.
-must_not_be: 'empty'
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php
deleted file mode 100644
index 15b43d28125cc4a2e30348dc76d972ce240443ac..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php
deleted file mode 100644
index 15b43d28125cc4a2e30348dc76d972ce240443ac..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt
deleted file mode 100644
index 0087269e33e50d1805db3c9ecf821660384c11bc..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt
+++ /dev/null
@@ -1 +0,0 @@
-This file should be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite
deleted file mode 100644
index 08874eba8bb924527069b41e1195da4b6b69d1dd..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite
+++ /dev/null
@@ -1 +0,0 @@
-This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm
deleted file mode 100644
index 08874eba8bb924527069b41e1195da4b6b69d1dd..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm
+++ /dev/null
@@ -1 +0,0 @@
-This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal
deleted file mode 100644
index 08874eba8bb924527069b41e1195da4b6b69d1dd..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal
+++ /dev/null
@@ -1 +0,0 @@
-This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt
deleted file mode 100644
index 08874eba8bb924527069b41e1195da4b6b69d1dd..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt
+++ /dev/null
@@ -1 +0,0 @@
-This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml
deleted file mode 100644
index f408d89e28e978d7e5cc3605b5e35eb81d122e3b..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-# This file should never be staged.
-key: "value"
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php
deleted file mode 100644
index 15b43d28125cc4a2e30348dc76d972ce240443ac..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php
deleted file mode 100644
index 15b43d28125cc4a2e30348dc76d972ce240443ac..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt
deleted file mode 100644
index 08874eba8bb924527069b41e1195da4b6b69d1dd..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt
+++ /dev/null
@@ -1 +0,0 @@
-This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
deleted file mode 100644
index e11552b41d40377475700ab10cd3118257d93cc7..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
+++ /dev/null
@@ -1 +0,0 @@
-# This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config b/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config
deleted file mode 100644
index 08874eba8bb924527069b41e1195da4b6b69d1dd..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config
+++ /dev/null
@@ -1 +0,0 @@
-This file should never be staged.
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
index bc57b4fa75a7b65aff5a4fca754e66d48c5c8191..12b4fe415ca89cc14febdada697392017b38e5a0 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
@@ -3,14 +3,14 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\EventSubscriber\ComposerExecutableValidator;
+use Drupal\package_manager\Validator\ComposerExecutableValidator;
 use Drupal\package_manager\ValidationResult;
 use PhpTuf\ComposerStager\Exception\IOException;
 use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinderInterface;
 use Prophecy\Argument;
 
 /**
- * @covers \Drupal\package_manager\EventSubscriber\ComposerExecutableValidator
+ * @covers \Drupal\package_manager\Validator\ComposerExecutableValidator
  *
  * @group package_manager
  */
@@ -120,7 +120,7 @@ public function testComposerVersionValidation(string $reported_version, array $e
       // $arguments, and we know exactly what that will contain: an array of
       // command arguments for Composer, and the validator object.
       ->will(function (array $arguments) use ($reported_version) {
-        /** @var \Drupal\package_manager\EventSubscriber\ComposerExecutableValidator $validator */
+        /** @var \Drupal\package_manager\Validator\ComposerExecutableValidator $validator */
         $validator = $arguments[1];
         // Invoke the validator (which, as mentioned, is a callback function),
         // with fake output from `composer --version`. It should try to tease a
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
index ff09f97428aeaa6ca03d7b51aad449faa78df02c..af6f12bde624b4aa748455f318d0b71ac8b8388f 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
@@ -3,35 +3,16 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\Component\Serialization\Json;
-use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\package_manager\Exception\StageValidationException;
-use Drupal\package_manager\PathLocator;
 use Drupal\package_manager\ValidationResult;
-use org\bovigo\vfs\vfsStream;
 
 /**
- * @covers \Drupal\package_manager\EventSubscriber\ComposerSettingsValidator
+ * @covers \Drupal\package_manager\Validator\ComposerSettingsValidator
  *
  * @group package_manager
  */
 class ComposerSettingsValidatorTest extends PackageManagerKernelTestBase {
 
-  /**
-   * {@inheritdoc}
-   */
-  protected function disableValidators(ContainerBuilder $container): void {
-    parent::disableValidators($container);
-
-    // Disable the disk space validator, since it tries to inspect the file
-    // system in ways that vfsStream doesn't support, like calling stat() and
-    // disk_free_space().
-    $container->removeDefinition('package_manager.validator.disk_space');
-
-    // Disable the lock file validator, since the mock file system we create in
-    // this test doesn't have any lock files to validate.
-    $container->removeDefinition('package_manager.validator.lock_file');
-  }
-
   /**
    * Data provider for ::testSecureHttpValidation().
    *
@@ -78,16 +59,10 @@ public function providerSecureHttpValidation(): array {
    * @dataProvider providerSecureHttpValidation
    */
   public function testSecureHttpValidation(string $contents, array $expected_results): void {
-    $file = vfsStream::newFile('composer.json')->setContent($contents);
-    $this->vfsRoot->addChild($file);
-
-    $active_dir = $this->vfsRoot->url();
-    $locator = $this->prophesize(PathLocator::class);
-    $locator->getActiveDirectory()->willReturn($active_dir);
-    $locator->getProjectRoot()->willReturn($active_dir);
-    $locator->getWebRoot()->willReturn('');
-    $locator->getVendorDirectory()->willReturn($active_dir);
-    $this->container->set('package_manager.path_locator', $locator->reveal());
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getActiveDirectory();
+    file_put_contents("$active_dir/composer.json", $contents);
 
     try {
       $this->createStage()->create();
diff --git a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
index 3195b51d3a14164f46b2cae143bf0224dbe7dfb2..a972c7e8a417910106e55072b7842a82e0c06cf7 100644
--- a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
@@ -2,44 +2,17 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
-use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\EventSubscriber\DiskSpaceValidator;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Component\Utility\Bytes;
 
 /**
- * @covers \Drupal\package_manager\EventSubscriber\DiskSpaceValidator
+ * @covers \Drupal\package_manager\Validator\DiskSpaceValidator
  *
  * @group package_manager
  */
 class DiskSpaceValidatorTest extends PackageManagerKernelTestBase {
 
-  /**
-   * {@inheritdoc}
-   */
-  public function register(ContainerBuilder $container) {
-    parent::register($container);
-
-    // Replace the validator under test with a mocked version which can be
-    // rigged up to return specific values for various filesystem checks.
-    $container->getDefinition('package_manager.validator.disk_space')
-      ->setClass(TestDiskSpaceValidator::class);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function disableValidators(ContainerBuilder $container): void {
-    parent::disableValidators($container);
-
-    // Disable the lock file and Composer settings validators, since in this
-    // test we are validating an imaginary file system which doesn't have any
-    // Composer files.
-    $container->removeDefinition('package_manager.validator.lock_file');
-    $container->removeDefinition('package_manager.validator.composer_settings');
-  }
-
   /**
    * Data provider for ::testDiskSpaceValidation().
    *
@@ -47,17 +20,21 @@ protected function disableValidators(ContainerBuilder $container): void {
    *   Sets of arguments to pass to the test method.
    */
   public function providerDiskSpaceValidation(): array {
-    $root_insufficient = t('Drupal root filesystem "root" has insufficient space. There must be at least 1024 megabytes free.');
-    $vendor_insufficient = t('Vendor filesystem "vendor" has insufficient space. There must be at least 1024 megabytes free.');
-    $temp_insufficient = t('Directory "temp" has insufficient space. There must be at least 1024 megabytes free.');
+    // These will be defined by ::createTestProject().
+    $root = 'vfs://root/active';
+    $vendor = "$root/vendor";
+
+    $root_insufficient = "Drupal root filesystem \"$root\" has insufficient space. There must be at least 1024 megabytes free.";
+    $vendor_insufficient = "Vendor filesystem \"$vendor\" has insufficient space. There must be at least 1024 megabytes free.";
+    $temp_insufficient = 'Directory "temp" has insufficient space. There must be at least 1024 megabytes free.';
     $summary = t("There is not enough disk space to create a staging area.");
 
     return [
       'shared, vendor and temp sufficient, root insufficient' => [
         TRUE,
         [
-          'root' => '1M',
-          'vendor' => '2G',
+          $root => '1M',
+          $vendor => '2G',
           'temp' => '4G',
         ],
         [
@@ -67,8 +44,8 @@ public function providerDiskSpaceValidation(): array {
       'shared, root and vendor insufficient, temp sufficient' => [
         TRUE,
         [
-          'root' => '1M',
-          'vendor' => '2M',
+          $root => '1M',
+          $vendor => '2M',
           'temp' => '2G',
         ],
         [
@@ -78,8 +55,8 @@ public function providerDiskSpaceValidation(): array {
       'shared, vendor and root sufficient, temp insufficient' => [
         TRUE,
         [
-          'root' => '2G',
-          'vendor' => '4G',
+          $root => '2G',
+          $vendor => '4G',
           'temp' => '1M',
         ],
         [
@@ -89,8 +66,8 @@ public function providerDiskSpaceValidation(): array {
       'shared, root and temp insufficient, vendor sufficient' => [
         TRUE,
         [
-          'root' => '1M',
-          'vendor' => '2G',
+          $root => '1M',
+          $vendor => '2G',
           'temp' => '2M',
         ],
         [
@@ -103,8 +80,8 @@ public function providerDiskSpaceValidation(): array {
       'not shared, root insufficient, vendor and temp sufficient' => [
         FALSE,
         [
-          'root' => '5M',
-          'vendor' => '1G',
+          $root => '5M',
+          $vendor => '1G',
           'temp' => '4G',
         ],
         [
@@ -114,8 +91,8 @@ public function providerDiskSpaceValidation(): array {
       'not shared, vendor insufficient, root and temp sufficient' => [
         FALSE,
         [
-          'root' => '2G',
-          'vendor' => '10M',
+          $root => '2G',
+          $vendor => '10M',
           'temp' => '4G',
         ],
         [
@@ -125,8 +102,8 @@ public function providerDiskSpaceValidation(): array {
       'not shared, root and vendor sufficient, temp insufficient' => [
         FALSE,
         [
-          'root' => '1G',
-          'vendor' => '2G',
+          $root => '1G',
+          $vendor => '2G',
           'temp' => '3M',
         ],
         [
@@ -136,8 +113,8 @@ public function providerDiskSpaceValidation(): array {
       'not shared, root and vendor insufficient, temp sufficient' => [
         FALSE,
         [
-          'root' => '500M',
-          'vendor' => '75M',
+          $root => '500M',
+          $vendor => '75M',
           'temp' => '2G',
         ],
         [
@@ -156,22 +133,17 @@ public function providerDiskSpaceValidation(): array {
    * @param bool $shared_disk
    *   Whether the root and vendor directories are on the same logical disk.
    * @param array $free_space
-   *   The free space that should be reported for various locations. The keys
-   *   are the locations (only 'root', 'vendor', and 'temp' are supported), and
-   *   the values are the space that should be reported, in a format that can be
-   *   parsed by \Drupal\Component\Utility\Bytes::toNumber().
+   *   The free space that should be reported for various paths. The keys
+   *   are the paths, and the values are the free space that should be reported,
+   *   in a format that can be parsed by
+   *   \Drupal\Component\Utility\Bytes::toNumber().
    * @param \Drupal\package_manager\ValidationResult[] $expected_results
    *   The expected validation results.
    *
    * @dataProvider providerDiskSpaceValidation
    */
   public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void {
-    $path_locator = $this->prophesize('\Drupal\package_manager\PathLocator');
-    $path_locator->getProjectRoot()->willReturn('root');
-    $path_locator->getWebRoot()->willReturn('');
-    $path_locator->getActiveDirectory()->willReturn('root');
-    $path_locator->getVendorDirectory()->willReturn('vendor');
-    $this->container->set('package_manager.path_locator', $path_locator->reveal());
+    $this->createTestProject();
 
     /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */
     $validator = $this->container->get('package_manager.validator.disk_space');
@@ -182,47 +154,3 @@ public function testDiskSpaceValidation(bool $shared_disk, array $free_space, ar
   }
 
 }
-
-/**
- * A test version of the disk space validator.
- */
-class TestDiskSpaceValidator extends DiskSpaceValidator {
-
-  /**
-   * Whether the root and vendor directories are on the same logical disk.
-   *
-   * @var bool
-   */
-  public $sharedDisk;
-
-  /**
-   * The amount of free space, keyed by location.
-   *
-   * @var float[]
-   */
-  public $freeSpace = [];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function stat(string $path): array {
-    return [
-      'dev' => $this->sharedDisk ? 'disk' : uniqid(),
-    ];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function freeSpace(string $path): float {
-    return $this->freeSpace[$path];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function temporaryDirectory(): string {
-    return 'temp';
-  }
-
-}
diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php
deleted file mode 100644
index 0489ad421300185aa4222b188dd7a3172fbefe5f..0000000000000000000000000000000000000000
--- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php
+++ /dev/null
@@ -1,99 +0,0 @@
-<?php
-
-namespace Drupal\Tests\package_manager\Kernel;
-
-use Drupal\Core\Database\Connection;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber;
-
-/**
- * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
- *
- * @group package_manager
- */
-class ExcludedPathsSubscriberTest extends PackageManagerKernelTestBase {
-
-  /**
-   * Data provider for ::testSqliteDatabaseExcluded().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerSqliteDatabaseExcluded(): array {
-    $drupal_root = $this->getDrupalRoot();
-
-    return [
-      'relative path, in site directory' => [
-        'sites/example.com/db.sqlite',
-        [
-          'sites/example.com/db.sqlite',
-          'sites/example.com/db.sqlite-shm',
-          'sites/example.com/db.sqlite-wal',
-        ],
-      ],
-      'relative path, at root' => [
-        'db.sqlite',
-        [
-          'db.sqlite',
-          'db.sqlite-shm',
-          'db.sqlite-wal',
-        ],
-      ],
-      'absolute path, in site directory' => [
-        $drupal_root . '/sites/example.com/db.sqlite',
-        [
-          'sites/example.com/db.sqlite',
-          'sites/example.com/db.sqlite-shm',
-          'sites/example.com/db.sqlite-wal',
-        ],
-      ],
-      'absolute path, at root' => [
-        $drupal_root . '/db.sqlite',
-        [
-          'db.sqlite',
-          'db.sqlite-shm',
-          'db.sqlite-wal',
-        ],
-      ],
-    ];
-  }
-
-  /**
-   * Tests that SQLite database paths are excluded from the staging area.
-   *
-   * The exclusion of SQLite databases from the staging area is functionally
-   * tested by \Drupal\Tests\package_manager\Functional\ExcludedPathsTest. The
-   * purpose of this test is to ensure that SQLite database paths are processed
-   * properly (e.g., converting an absolute path to a relative path) before
-   * being flagged for exclusion.
-   *
-   * @param string $database
-   *   The path of the SQLite database, as set in the database connection
-   *   options.
-   * @param string[] $expected_exclusions
-   *   The database paths which should be flagged for exclusion.
-   *
-   * @dataProvider providerSqliteDatabaseExcluded
-   *
-   * @see \Drupal\Tests\package_manager\Functional\ExcludedPathsTest
-   */
-  public function testSqliteDatabaseExcluded(string $database, array $expected_exclusions): void {
-    $connection = $this->prophesize(Connection::class);
-    $connection->driver()->willReturn('sqlite');
-    $connection->getConnectionOptions()->willReturn(['database' => $database]);
-
-    $subscriber = new ExcludedPathsSubscriber(
-      'sites/default',
-      $this->container->get('package_manager.symfony_file_system'),
-      $this->container->get('stream_wrapper_manager'),
-      $connection->reveal(),
-      $this->container->get('package_manager.path_locator')
-    );
-
-    $event = new PreCreateEvent($this->createStage());
-    $subscriber->ignoreCommonPaths($event);
-    // All of the expected exclusions should be flagged.
-    $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
-  }
-
-}
diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
index b9a8eadbe36a68e429f99ef60562c5b48319d4e7..776c0ff9a0b1737a4c9d78f5ebcbd3528ea682ec 100644
--- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
@@ -3,10 +3,8 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\Core\Database\Connection;
-use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber;
-use Drupal\package_manager\PathLocator;
-use org\bovigo\vfs\vfsStream;
 
 /**
  * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
@@ -16,153 +14,56 @@
 class ExcludedPathsTest extends PackageManagerKernelTestBase {
 
   /**
-   * {@inheritdoc}
+   * The mocked SQLite database connection.
+   *
+   * @var \Drupal\Core\Database\Connection|\Prophecy\Prophecy\ObjectProphecy
    */
-  protected function setUp(): void {
-    parent::setUp();
-
-    // Ensure that any staging directories created by TestStage are created
-    // in the virtual file system.
-    TestStage::$stagingRoot = $this->vfsRoot->url();
-
-    // We need to rebuild the container after setting a private file path, since
-    // the private stream wrapper is only registered if this setting is set.
-    // @see \Drupal\Core\CoreServiceProvider::register()
-    $this->setSetting('file_private_path', 'private');
-    $kernel = $this->container->get('kernel');
-    $kernel->rebuildContainer();
-    $this->container = $kernel->getContainer();
-  }
+  private $mockDatabase;
 
   /**
    * {@inheritdoc}
    */
-  public function register(ContainerBuilder $container) {
+  protected function setUp(): void {
+    parent::setUp();
+
     // Normally, package_manager_bypass will disable all the actual staging
     // operations. In this case, we want to perform them so that we can be sure
     // that files are staged as expected.
     $this->setSetting('package_manager_bypass_stager', FALSE);
+    // The private stream wrapper is only registered if this setting is set.
+    // @see \Drupal\Core\CoreServiceProvider::register()
+    $this->setSetting('file_private_path', 'private');
 
-    $container->getDefinition('package_manager.excluded_paths_subscriber')
-      ->setClass(TestExcludedPathsSubscriber::class);
-
-    parent::register($container);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function disableValidators(ContainerBuilder $container): void {
-    parent::disableValidators($container);
-
-    // Disable the disk space validator, since it tries to inspect the file
-    // system in ways that vfsStream doesn't support, like calling stat() and
-    // disk_free_space().
-    $container->removeDefinition('package_manager.validator.disk_space');
+    // Rebuild the container to make the new settings take effect.
+    $kernel = $this->container->get('kernel');
+    $kernel->rebuildContainer();
+    $this->container = $kernel->getContainer();
 
-    // Disable the lock file and Composer settings validators, since in this
-    // test we have an imaginary file system without any Composer files.
-    $container->removeDefinition('package_manager.validator.lock_file');
+    // Mock a SQLite database connection so we can test that the subscriber will
+    // exclude the database files.
+    $this->mockDatabase = $this->prophesize(Connection::class);
+    $this->mockDatabase->driver()->willReturn('sqlite');
   }
 
   /**
    * Tests that certain paths are excluded from staging operations.
    */
   public function testExcludedPaths(): void {
-    $site = [
-      'composer.json' => '{}',
-      'private' => [
-        'ignore.txt' => 'This file should never be staged.',
-      ],
-      'sites' => [
-        'default' => [
-          'services.yml' => <<<END
-# This file should never be staged.
-must_not_be: 'empty'
-END,
-          'settings.local.php' => <<<END
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
-END,
-          'settings.php' => <<<END
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
-END,
-          'stage.txt' => 'This file should be staged.',
-        ],
-        'example.com' => [
-          'files' => [
-            'ignore.txt' => 'This file should never be staged.',
-          ],
-          'db.sqlite' => 'This file should never be staged.',
-          'db.sqlite-shm' => 'This file should never be staged.',
-          'db.sqlite-wal' => 'This file should never be staged.',
-          'services.yml' => <<<END
-# This file should never be staged.
-key: "value"
-END,
-          'settings.local.php' => <<<END
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
-END,
-          'settings.php' => <<<END
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
-END,
-        ],
-        'simpletest' => [
-          'ignore.txt' => 'This file should never be staged.',
-        ],
-      ],
-      'vendor' => [
-        '.htaccess' => '# This file should never be staged.',
-        'web.config' => 'This file should never be staged.',
-      ],
-    ];
-    vfsStream::create(['active' => $site], $this->vfsRoot);
-
-    $active_dir = $this->vfsRoot->getChild('active')->url();
-
-    $path_locator = $this->prophesize(PathLocator::class);
-    $path_locator->getActiveDirectory()->willReturn($active_dir);
-    $path_locator->getProjectRoot()->willReturn($active_dir);
-    $path_locator->getWebRoot()->willReturn('');
-    $path_locator->getVendorDirectory()->willReturn("$active_dir/vendor");
-    $this->container->set('package_manager.path_locator', $path_locator->reveal());
+    $this->createTestProject();
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getActiveDirectory();
 
     $site_path = 'sites/example.com';
     // Ensure that we are using directories within the fake site fixture for
     // public and private files.
     $this->setSetting('file_public_path', "$site_path/files");
 
-    /** @var \Drupal\Tests\package_manager\Kernel\TestExcludedPathsSubscriber $subscriber */
-    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
-    $subscriber->sitePath = $site_path;
-
     // Mock a SQLite database connection to a file in the active directory. The
     // file should not be staged.
-    $database = $this->prophesize(Connection::class);
-    $database->driver()->willReturn('sqlite');
-    $database->getConnectionOptions()->willReturn([
+    $this->mockDatabase->getConnectionOptions()->willReturn([
       'database' => $site_path . '/db.sqlite',
     ]);
-    $subscriber->database = $database->reveal();
+    $this->setUpSubscriber($site_path);
 
     $stage = $this->createStage();
     $stage->create();
@@ -185,6 +86,9 @@ public function testExcludedPaths(): void {
       'sites/default/settings.php',
       'sites/default/settings.local.php',
       'sites/default/services.yml',
+      // No git directories should be staged.
+      '.git/ignore.txt',
+      'modules/example/.git/ignore.txt',
     ];
     foreach ($ignore as $path) {
       $this->assertFileExists("$active_dir/$path");
@@ -192,6 +96,10 @@ public function testExcludedPaths(): void {
     }
     // A non-excluded file in the default site directory should be staged.
     $this->assertFileExists("$stage_dir/sites/default/stage.txt");
+    // Regular module files should be staged.
+    $this->assertFileExists("$stage_dir/modules/example/example.info.yml");
+    // Files that start with .git, but aren't actually .git, should be staged.
+    $this->assertFileExists("$stage_dir/.gitignore");
 
     // A new file added to the staging area in an excluded directory, should not
     // be copied to the active directory.
@@ -207,21 +115,94 @@ public function testExcludedPaths(): void {
     }
   }
 
-}
-
-/**
- * A test-only implementation of the excluded path event subscriber.
- */
-class TestExcludedPathsSubscriber extends ExcludedPathsSubscriber {
+  /**
+   * Data provider for ::testSqliteDatabaseExcluded().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerSqliteDatabaseExcluded(): array {
+    $drupal_root = $this->getDrupalRoot();
+
+    return [
+      'relative path, in site directory' => [
+        'sites/example.com/db.sqlite',
+        [
+          'sites/example.com/db.sqlite',
+          'sites/example.com/db.sqlite-shm',
+          'sites/example.com/db.sqlite-wal',
+        ],
+      ],
+      'relative path, at root' => [
+        'db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+      'absolute path, in site directory' => [
+        $drupal_root . '/sites/example.com/db.sqlite',
+        [
+          'sites/example.com/db.sqlite',
+          'sites/example.com/db.sqlite-shm',
+          'sites/example.com/db.sqlite-wal',
+        ],
+      ],
+      'absolute path, at root' => [
+        $drupal_root . '/db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+    ];
+  }
 
   /**
-   * {@inheritdoc}
+   * Tests that SQLite database paths are excluded from the staging area.
+   *
+   * The exclusion of SQLite databases from the staging area is functionally
+   * tested by \Drupal\Tests\package_manager\Functional\ExcludedPathsTest. The
+   * purpose of this test is to ensure that SQLite database paths are processed
+   * properly (e.g., converting an absolute path to a relative path) before
+   * being flagged for exclusion.
+   *
+   * @param string $database
+   *   The path of the SQLite database, as set in the database connection
+   *   options.
+   * @param string[] $expected_exclusions
+   *   The database paths which should be flagged for exclusion.
+   *
+   * @dataProvider providerSqliteDatabaseExcluded
    */
-  public $sitePath;
+  public function testSqliteDatabaseExcluded(string $database, array $expected_exclusions): void {
+    $this->mockDatabase->getConnectionOptions()->willReturn([
+      'database' => $database,
+    ]);
+
+    $event = new PreCreateEvent($this->createStage());
+    $this->setUpSubscriber();
+    $this->container->get('package_manager.excluded_paths_subscriber')->ignoreCommonPaths($event);
+    // All of the expected exclusions should be flagged.
+    $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
+  }
 
   /**
-   * {@inheritdoc}
+   * Sets up the event subscriber with a mocked database and site path.
+   *
+   * @param string $site_path
+   *   (optional) The site path. Defaults to 'sites/default'.
    */
-  public $database;
+  private function setUpSubscriber(string $site_path = 'sites/default'): void {
+    $this->container->set('package_manager.excluded_paths_subscriber', new ExcludedPathsSubscriber(
+      $site_path,
+      $this->container->get('package_manager.symfony_file_system'),
+      $this->container->get('stream_wrapper_manager'),
+      $this->mockDatabase->reveal(),
+      $this->container->get('package_manager.path_locator')
+    ));
+  }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
index c71a6d27e46af85c4cba3296a14554184ab09e98..c6cfeb535d4c043213216be339947822b2781aed 100644
--- a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
@@ -2,53 +2,34 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
-use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
-use Drupal\package_manager\EventSubscriber\LockFileValidator;
-use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\Validator\LockFileValidator;
 use Drupal\package_manager\ValidationResult;
-use org\bovigo\vfs\vfsStream;
 
 /**
- * @coversDefaultClass \Drupal\package_manager\EventSubscriber\LockFileValidator
+ * @coversDefaultClass \Drupal\package_manager\Validator\LockFileValidator
  *
  * @group package_manager
  */
 class LockFileValidatorTest extends PackageManagerKernelTestBase {
 
   /**
-   * {@inheritdoc}
+   * The path of the active directory in the virtual file system.
+   *
+   * @var string
    */
-  protected function setUp(): void {
-    parent::setUp();
-
-    $vendor = vfsStream::newDirectory('vendor');
-    $this->vfsRoot->addChild($vendor);
-
-    $path_locator = $this->prophesize(PathLocator::class);
-    $path_locator->getActiveDirectory()->willReturn($this->vfsRoot->url());
-    $path_locator->getProjectRoot()->willReturn($this->vfsRoot->url());
-    $path_locator->getWebRoot()->willReturn('');
-    $path_locator->getVendorDirectory()->willReturn($vendor->url());
-    $this->container->set('package_manager.path_locator', $path_locator->reveal());
-  }
+  private $activeDir;
 
   /**
    * {@inheritdoc}
    */
-  protected function disableValidators(ContainerBuilder $container): void {
-    parent::disableValidators($container);
-
-    // Disable the disk space validator, since it tries to inspect the file
-    // system in ways that vfsStream doesn't support, like calling stat() and
-    // disk_free_space().
-    $container->removeDefinition('package_manager.validator.disk_space');
-
-    // Disable the Composer settings validator, since it tries to read Composer
-    // files that may not exist in this test.
-    $container->removeDefinition('package_manager.validator.composer_settings');
+  protected function setUp(): void {
+    parent::setUp();
+    $this->createTestProject();
+    $this->activeDir = $this->container->get('package_manager.path_locator')
+      ->getActiveDirectory();
   }
 
   /**
@@ -57,6 +38,8 @@ protected function disableValidators(ContainerBuilder $container): void {
    * @covers ::storeHash
    */
   public function testCreateWithNoLock(): void {
+    unlink($this->activeDir . '/composer.lock');
+
     $no_lock = ValidationResult::createError(['Could not hash the active lock file.']);
     $this->assertResults([$no_lock], PreCreateEvent::class);
   }
@@ -68,12 +51,11 @@ public function testCreateWithNoLock(): void {
    * @covers ::deleteHash
    */
   public function testCreateWithLock(): void {
-    $this->createActiveLockFile();
     $this->assertResults([]);
 
     // Change the lock file to ensure the stored hash of the previous version
     // has been deleted.
-    $this->vfsRoot->getChild('composer.lock')->setContent('"changed"');
+    file_put_contents($this->activeDir . '/composer.lock', 'changed');
     $this->assertResults([]);
   }
 
@@ -83,14 +65,12 @@ public function testCreateWithLock(): void {
    * @dataProvider providerValidateStageEvents
    */
   public function testLockFileChanged(string $event_class): void {
-    $this->createActiveLockFile();
-
     // Add a listener with an extremely high priority to the same event that
     // should raise the validation error. Because the validator uses the default
     // priority of 0, this listener changes lock file before the validator
     // runs.
     $this->addListener($event_class, function () {
-      $this->vfsRoot->getChild('composer.lock')->setContent('"changed"');
+      file_put_contents($this->activeDir . '/composer.lock', 'changed');
     });
     $result = ValidationResult::createError([
       'Stored lock file hash does not match the active lock file.',
@@ -104,14 +84,12 @@ public function testLockFileChanged(string $event_class): void {
    * @dataProvider providerValidateStageEvents
    */
   public function testLockFileDeleted(string $event_class): void {
-    $this->createActiveLockFile();
-
     // Add a listener with an extremely high priority to the same event that
     // should raise the validation error. Because the validator uses the default
     // priority of 0, this listener deletes lock file before the validator
     // runs.
     $this->addListener($event_class, function () {
-      $this->vfsRoot->removeChild('composer.lock');
+      unlink($this->activeDir . '/composer.lock');
     });
     $result = ValidationResult::createError([
       'Could not hash the active lock file.',
@@ -125,8 +103,6 @@ public function testLockFileDeleted(string $event_class): void {
    * @dataProvider providerValidateStageEvents
    */
   public function testNoStoredHash(string $event_class): void {
-    $this->createActiveLockFile();
-
     $reflector = new \ReflectionClassConstant(LockFileValidator::class, 'STATE_KEY');
     $state_key = $reflector->getValue();
 
@@ -160,14 +136,6 @@ public function providerValidateStageEvents(): array {
     ];
   }
 
-  /**
-   * Creates a 'composer.lock' file in the active directory.
-   */
-  private function createActiveLockFile(): void {
-    $lock_file = vfsStream::newFile('composer.lock')->setContent('{}');
-    $this->vfsRoot->addChild($lock_file);
-  }
-
   /**
    * Adds an event listener with the highest possible priority.
    *
diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
index e6e7b7504c069ddc200acb5dc51cacaff35c3941..7c770fe543ab76a735bbb95d5688774fc41553a8 100644
--- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
+++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -5,10 +5,13 @@
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Validator\DiskSpaceValidator;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\PathLocator;
 use Drupal\package_manager\Stage;
 use Drupal\Tests\package_manager\Traits\ValidationTestTrait;
+use org\bovigo\vfs\vfsStream;
 
 /**
  * Base class for kernel tests of Package Manager's functionality.
@@ -119,6 +122,129 @@ protected function registerPostUpdateFunctions(): void {
       ->set('existing_updates', $updates);
   }
 
+  /**
+   * Creates a test project in a virtual file system.
+   *
+   * This will create two directories at the root of the virtual file system:
+   * 'active', which is the active directory containing a fake Drupal code base,
+   * and 'stage', which is the root directory used to stage changes. The path
+   * locator service will also be mocked so that it points to the test project.
+   */
+  protected function createTestProject(): void {
+    $tree = [
+      'active' => [
+        'composer.json' => '{}',
+        'composer.lock' => '{}',
+        '.git' => [
+          'ignore.txt' => 'This file should never be staged.',
+        ],
+        '.gitignore' => 'This file should be staged.',
+        'private' => [
+          'ignore.txt' => 'This file should never be staged.',
+        ],
+        'modules' => [
+          'example' => [
+            'example.info.yml' => 'This file should be staged.',
+            '.git' => [
+              'ignore.txt' => 'This file should never be staged.',
+            ],
+          ],
+        ],
+        'sites' => [
+          'default' => [
+            'services.yml' => <<<END
+# This file should never be staged.
+must_not_be: 'empty'
+END,
+            'settings.local.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+            'settings.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+            'stage.txt' => 'This file should be staged.',
+          ],
+          'example.com' => [
+            'files' => [
+              'ignore.txt' => 'This file should never be staged.',
+            ],
+            'db.sqlite' => 'This file should never be staged.',
+            'db.sqlite-shm' => 'This file should never be staged.',
+            'db.sqlite-wal' => 'This file should never be staged.',
+            'services.yml' => <<<END
+# This file should never be staged.
+key: "value"
+END,
+            'settings.local.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+            'settings.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+          ],
+          'simpletest' => [
+            'ignore.txt' => 'This file should never be staged.',
+          ],
+        ],
+        'vendor' => [
+          '.htaccess' => '# This file should never be staged.',
+          'web.config' => 'This file should never be staged.',
+        ],
+      ],
+      'stage' => [],
+    ];
+    $root = vfsStream::create($tree, $this->vfsRoot)->url();
+    $active_dir = "$root/active";
+    TestStage::$stagingRoot = "$root/stage";
+
+    $path_locator = $this->prophesize(PathLocator::class);
+    $path_locator->getActiveDirectory()->willReturn($active_dir);
+    $path_locator->getProjectRoot()->willReturn($active_dir);
+    $path_locator->getWebRoot()->willReturn('');
+    $path_locator->getVendorDirectory()->willReturn("$active_dir/vendor");
+
+    // We won't need the prophet anymore.
+    $path_locator = $path_locator->reveal();
+    $this->container->set('package_manager.path_locator', $path_locator);
+
+    // Since the path locator now points to a virtual file system, we need to
+    // replace the disk space validator with a test-only version that bypasses
+    // system calls, like disk_free_space() and stat(), which aren't supported
+    // by vfsStream.
+    $validator = new TestDiskSpaceValidator(
+      $this->container->get('package_manager.path_locator'),
+      $this->container->get('string_translation')
+    );
+    // By default, the validator should report that the root, vendor, and
+    // temporary directories have basically infinite free space.
+    $validator->freeSpace = [
+      $path_locator->getActiveDirectory() => PHP_INT_MAX,
+      $path_locator->getVendorDirectory() => PHP_INT_MAX,
+      $validator->temporaryDirectory() => PHP_INT_MAX,
+    ];
+    $this->container->set('package_manager.validator.disk_space', $validator);
+  }
+
 }
 
 /**
@@ -156,3 +282,47 @@ protected function dispatch(StageEvent $event): void {
   }
 
 }
+
+/**
+ * A test version of the disk space validator to bypass system-level functions.
+ */
+class TestDiskSpaceValidator extends DiskSpaceValidator {
+
+  /**
+   * Whether the root and vendor directories are on the same logical disk.
+   *
+   * @var bool
+   */
+  public $sharedDisk = TRUE;
+
+  /**
+   * The amount of free space, keyed by path.
+   *
+   * @var float[]
+   */
+  public $freeSpace = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function stat(string $path): array {
+    return [
+      'dev' => $this->sharedDisk ? 'disk' : uniqid(),
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function freeSpace(string $path): float {
+    return $this->freeSpace[$path];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function temporaryDirectory(): string {
+    return 'temp';
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
index ea90029ca28aa001c035cb076bf868158b580dca..35605045ea2b3b4b50816f875400338e6ec8c85d 100644
--- a/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
@@ -6,7 +6,7 @@
 use Drupal\package_manager\ValidationResult;
 
 /**
- * @covers \Drupal\package_manager\EventSubscriber\PendingUpdatesValidator
+ * @covers \Drupal\package_manager\Validator\PendingUpdatesValidator
  *
  * @group package_manager
  */
diff --git a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
index eb5f46c1b45bfd05691a3258960b5af4856082ef..8321d259ea454984a49942e1db021a5baa86a185 100644
--- a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
@@ -13,7 +13,6 @@
 use Drupal\package_manager\Event\PreRequireEvent;
 use Drupal\package_manager\Event\StageEvent;
 use Drupal\package_manager\Exception\StageValidationException;
-use Drupal\package_manager\Stage;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
@@ -44,16 +43,7 @@ class StageEventsTest extends PackageManagerKernelTestBase implements EventSubsc
    */
   protected function setUp(): void {
     parent::setUp();
-
-    $this->stage = new Stage(
-      $this->container->get('package_manager.path_locator'),
-      $this->container->get('package_manager.beginner'),
-      $this->container->get('package_manager.stager'),
-      $this->container->get('package_manager.committer'),
-      $this->container->get('file_system'),
-      $this->container->get('event_dispatcher'),
-      $this->container->get('tempstore.shared')
-    );
+    $this->stage = $this->createStage();
   }
 
   /**
diff --git a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
index 21cf1b2007743ac6a29417d8dadaae6a0dc3d816..3686bd29af7fe3ca82e17c9297ee20407dfec6ea 100644
--- a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
@@ -3,11 +3,9 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\EventSubscriber\WritableFileSystemValidator;
+use Drupal\package_manager\Validator\WritableFileSystemValidator;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\package_manager\PathLocator;
-use org\bovigo\vfs\vfsStream;
 
 /**
  * Unit tests the file system permissions validator.
@@ -17,7 +15,7 @@
  *
  * @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
  *
- * @covers \Drupal\package_manager\EventSubscriber\WritableFileSystemValidator
+ * @covers \Drupal\package_manager\Validator\WritableFileSystemValidator
  *
  * @group package_manager
  */
@@ -39,16 +37,8 @@ public function register(ContainerBuilder $container) {
    * {@inheritdoc}
    */
   protected function disableValidators(ContainerBuilder $container): void {
-    // Disable the disk space validator, since it tries to inspect the file
-    // system in ways that vfsStream doesn't support, like calling stat() and
-    // disk_free_space().
-    $container->removeDefinition('package_manager.validator.disk_space');
-
-    // Disable the lock file and Composer settings validators, since in this
-    // test we are validating an imaginary file system which doesn't have any
-    // Composer files.
-    $container->removeDefinition('package_manager.validator.lock_file');
-    $container->removeDefinition('package_manager.validator.composer_settings');
+    // The parent method disables the validator we're testing, so we don't want
+    // to do anything here.
   }
 
   /**
@@ -58,8 +48,9 @@ protected function disableValidators(ContainerBuilder $container): void {
    *   Sets of arguments to pass to the test method.
    */
   public function providerWritable(): array {
-    $root_error = t('The Drupal directory "vfs://root" is not writable.');
-    $vendor_error = t('The vendor directory "vfs://root/vendor" is not writable.');
+    // The root and vendor paths are defined by ::createTestProject().
+    $root_error = 'The Drupal directory "vfs://root/active" is not writable.';
+    $vendor_error = 'The vendor directory "vfs://root/active/vendor" is not writable.';
     $summary = t('The file system is not writable.');
     $writable_permission = 0777;
     $non_writable_permission = 0444;
@@ -107,20 +98,16 @@ public function providerWritable(): array {
    * @dataProvider providerWritable
    */
   public function testWritable(int $root_permissions, int $vendor_permissions, array $expected_results): void {
-    $root = vfsStream::setup('root', $root_permissions);
-    $vendor = vfsStream::newDirectory('vendor', $vendor_permissions);
-    $root->addChild($vendor);
-
-    $path_locator = $this->prophesize(PathLocator::class);
-    $path_locator->getActiveDirectory()->willReturn($root->url());
-    $path_locator->getProjectRoot()->willReturn($root->url());
-    $path_locator->getWebRoot()->willReturn('');
-    $path_locator->getVendorDirectory()->willReturn($vendor->url());
-    $this->container->set('package_manager.path_locator', $path_locator->reveal());
+    $this->createTestProject();
+    // For reasons unclear, the built-in chmod() function doesn't seem to work
+    // when changing vendor permissions, so just call vfsStream's API directly.
+    $active_dir = $this->vfsRoot->getChild('active');
+    $active_dir->chmod($root_permissions);
+    $active_dir->getChild('vendor')->chmod($vendor_permissions);
 
     /** @var \Drupal\Tests\package_manager\Kernel\TestWritableFileSystemValidator $validator */
     $validator = $this->container->get('package_manager.validator.file_system');
-    $validator->appRoot = $root->url();
+    $validator->appRoot = $active_dir->url();
 
     $this->assertResults($expected_results, PreCreateEvent::class);
   }