From ebf567c2e723571cbdfcf98276ac5a401a896dd1 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 31 Oct 2026 13:05:17 +0100 Subject: [PATCH 1/3] Leave files of the scenario out of the installation cache The installation cache holds everything a WordPress installation adds to the core files, and is created by whichever scenario installs WordPress first. Files that scenario created before installing, such as a wp-cli.yml, also ended up in the cache, and from there in every later scenario set up from it. Which scenario that is depends on the order in which they run, and differs between parallel workers. Leave out what was in the run directory before the installation started. Creating the subdirectory of an installation also no longer fails when the scenario created files in it already. Co-Authored-By: Claude Opus 5.5 --- features/testing.feature | 16 ++++++++++ src/Context/FeatureContext.php | 55 +++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/features/testing.feature b/features/testing.feature index 022fdda3..bea30843 100644 --- a/features/testing.feature +++ b/features/testing.feature @@ -37,6 +37,22 @@ Feature: Test that WP-CLI loads. sqlite """ + Scenario: Leave files a scenario created before installing out of the installation cache + Given an empty directory + And a scenario-files/wp-cli.yml file: + """ + user: admin + """ + And a WP installation in 'scenario-files' + Then the scenario-files/wp-cli.yml file should exist + + When I run `php -r "rename('scenario-files', 'first');"` + + # Reuse the subdirectory to select the same installation cache. + Given a WP installation in 'scenario-files' + Then the scenario-files/wp-config.php file should exist + And the scenario-files/wp-cli.yml file should not exist + @require-sqlite Scenario: Restore independent SQLite databases from the installation cache Given a WP installation in 'cached' diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php index ba8ae9f5..036174b7 100644 --- a/src/Context/FeatureContext.php +++ b/src/Context/FeatureContext.php @@ -1843,7 +1843,8 @@ public function download_wp( $subdir = '', $version = '' ): void { $dest_dir = rtrim( $this->variables['RUN_DIR'], '/\\' ) . ( $subdir ? DIRECTORY_SEPARATOR . $subdir : '' ); - if ( $subdir ) { + // The scenario may have created files in the subdirectory already. + if ( $subdir && ! is_dir( $dest_dir ) ) { mkdir( $dest_dir, 0777, true /*recursive*/ ); } @@ -1937,6 +1938,11 @@ public function install_wp( $subdir = '', $version = '' ): void { $this->create_db(); } $this->create_run_dir(); + + // Files the scenario created before installing belong to that scenario only, and must not + // end up in the installation cache that later scenarios are set up from. + $scenario_files = self::list_dir( '' !== $subdir ? ( $this->variables['RUN_DIR'] . "/$subdir" ) : $this->variables['RUN_DIR'] ); + $this->download_wp( $subdir, $version ); $this->create_config( $subdir, $config_extra_php ); @@ -1969,6 +1975,7 @@ public function install_wp( $subdir = '', $version = '' ): void { } self::dir_diff_copy( $run_dir, self::$cache_dir, $install_cache_path ); + self::remove_from_dir( $install_cache_path, $scenario_files ); if ( 'sqlite' !== self::$db_type ) { $mysqldump_binary = Utils\get_sql_dump_command(); @@ -2239,6 +2246,52 @@ private static function log_run_times_after_scenario( $scope ): void { } } + /** + * List the files and directories within a directory, relative to it. + * + * @param string $dir + * @return array Relative paths, directories after their contents. Empty if the directory does not exist. + */ + private static function list_dir( $dir ): array { + if ( ! is_dir( $dir ) ) { + return []; + } + + $paths = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + /** + * @var \SplFileInfo $item + */ + foreach ( $iterator as $item ) { + $paths[] = substr( $item->getPathname(), strlen( $dir ) + 1 ); + } + + return $paths; + } + + /** + * Remove the given files from a directory, as well as the given directories once they are empty. + * + * @param string $dir + * @param array $paths Relative paths, directories after their contents. + */ + private static function remove_from_dir( $dir, array $paths ): void { + foreach ( $paths as $path ) { + $full_path = "{$dir}/{$path}"; + + if ( is_dir( $full_path ) && ! is_link( $full_path ) ) { + // Only succeeds for directories that are empty by now. + @rmdir( $full_path ); + } elseif ( file_exists( $full_path ) || is_link( $full_path ) ) { + unlink( $full_path ); + } + } + } + /** * Copy files in updated directory that are not in source directory to copy directory. ("Incremental backup".) * Note: does not deal with changed files (ie does not compare file contents for changes), for speed reasons. From 38a1afebadebc0eca31b8701062c2704979c518e Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 31 Oct 2026 16:44:54 +0100 Subject: [PATCH 2/3] Leave what scenario symlinks pointed to out of the installation cache Co-Authored-By: Claude Opus 5.5 --- features/testing.feature | 19 +++++++++++++++++++ src/Context/FeatureContext.php | 20 ++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/features/testing.feature b/features/testing.feature index bea30843..8c850d5b 100644 --- a/features/testing.feature +++ b/features/testing.feature @@ -53,6 +53,25 @@ Feature: Test that WP-CLI loads. Then the scenario-files/wp-config.php file should exist And the scenario-files/wp-cli.yml file should not exist + @skip-windows + Scenario: Leave what a scenario linked to before installing out of the installation cache + Given an empty directory + And a linked-files/wp-cli.yml file: + """ + user: admin + """ + When I run `php -r "mkdir('scenario-links'); symlink(getcwd() . '/linked-files', 'scenario-links/linked');"` + + Given a WP installation in 'scenario-links' + Then the scenario-links/linked/wp-cli.yml file should exist + + When I run `php -r "rename('scenario-links', 'first');"` + + # Reuse the subdirectory to select the same installation cache. + Given a WP installation in 'scenario-links' + Then the scenario-links/wp-config.php file should exist + And the scenario-links/linked directory should not exist + @require-sqlite Scenario: Restore independent SQLite databases from the installation cache Given a WP installation in 'cached' diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php index 036174b7..b103735a 100644 --- a/src/Context/FeatureContext.php +++ b/src/Context/FeatureContext.php @@ -2249,8 +2249,11 @@ private static function log_run_times_after_scenario( $scope ): void { /** * List the files and directories within a directory, relative to it. * + * Symbolic links are listed, but not followed. + * * @param string $dir - * @return array Relative paths, directories after their contents. Empty if the directory does not exist. + * @return array Whether each entry is a symbolic link, keyed by relative path, directories after + * their contents. Empty if the directory does not exist. */ private static function list_dir( $dir ): array { if ( ! is_dir( $dir ) ) { @@ -2267,7 +2270,7 @@ private static function list_dir( $dir ): array { * @var \SplFileInfo $item */ foreach ( $iterator as $item ) { - $paths[] = substr( $item->getPathname(), strlen( $dir ) + 1 ); + $paths[ substr( $item->getPathname(), strlen( $dir ) + 1 ) ] = $item->isLink(); } return $paths; @@ -2276,14 +2279,19 @@ private static function list_dir( $dir ): array { /** * Remove the given files from a directory, as well as the given directories once they are empty. * - * @param string $dir - * @param array $paths Relative paths, directories after their contents. + * A copy of what a symbolic link pointed to is removed with all its contents. + * + * @param string $dir + * @param array $paths Whether each entry was a symbolic link, keyed by relative path, directories + * after their contents. */ private static function remove_from_dir( $dir, array $paths ): void { - foreach ( $paths as $path ) { + foreach ( $paths as $path => $was_link ) { $full_path = "{$dir}/{$path}"; - if ( is_dir( $full_path ) && ! is_link( $full_path ) ) { + if ( $was_link && is_dir( $full_path ) && ! is_link( $full_path ) ) { + self::remove_dir( $full_path ); + } elseif ( is_dir( $full_path ) && ! is_link( $full_path ) ) { // Only succeeds for directories that are empty by now. @rmdir( $full_path ); } elseif ( file_exists( $full_path ) || is_link( $full_path ) ) { From c11d155d3d1e044bae44b6c829cfa9884cf85a88 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 31 Oct 2026 16:51:51 +0100 Subject: [PATCH 3/3] Strip a trailing slash before listing the files of the scenario Co-Authored-By: Claude Opus 5.5 --- features/testing.feature | 4 ++-- src/Context/FeatureContext.php | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/features/testing.feature b/features/testing.feature index 8c850d5b..bbab3a22 100644 --- a/features/testing.feature +++ b/features/testing.feature @@ -43,13 +43,13 @@ Feature: Test that WP-CLI loads. """ user: admin """ - And a WP installation in 'scenario-files' + And a WP installation in 'scenario-files/' Then the scenario-files/wp-cli.yml file should exist When I run `php -r "rename('scenario-files', 'first');"` # Reuse the subdirectory to select the same installation cache. - Given a WP installation in 'scenario-files' + Given a WP installation in 'scenario-files/' Then the scenario-files/wp-config.php file should exist And the scenario-files/wp-cli.yml file should not exist diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php index b103735a..cbc2e464 100644 --- a/src/Context/FeatureContext.php +++ b/src/Context/FeatureContext.php @@ -2256,6 +2256,7 @@ private static function log_run_times_after_scenario( $scope ): void { * their contents. Empty if the directory does not exist. */ private static function list_dir( $dir ): array { + $dir = rtrim( $dir, '/\\' ); if ( ! is_dir( $dir ) ) { return []; }