diff --git a/.vortex/docs/content/development/variables.mdx b/.vortex/docs/content/development/variables.mdx index 8bd14836c..adbbec9ad 100644 --- a/.vortex/docs/content/development/variables.mdx +++ b/.vortex/docs/content/development/variables.mdx @@ -239,6 +239,7 @@ The list below is automatically generated with [Shellvar](https://github.com/ale | `VORTEX_EXPORT_DB_CONTAINER_REGISTRY` | Container registry name. | `docker.io` | `.vortex/tooling/src/vortex-export-db-image` | | `VORTEX_EXPORT_DB_CONTAINER_REGISTRY_PUSH_PROCEED` | Proceed with container image push after it was exported. | `UNDEFINED` | `CI config` | | `VORTEX_EXPORT_DB_FILE_DIR` | Directory with database dump file. | `./.data` | `.vortex/tooling/src/vortex-export-db-file` | +| `VORTEX_EXPORT_DB_FILE_STRUCTURE_TABLES` | Tables to export with their structure but without their data. Accepts a comma-separated list where each entry may use the `*` wildcard. Drupal rebuilds cache tables on demand, so their contents only inflate the dump. Set to an empty value to export the data of every table. | `cache*` | `.vortex/tooling/src/vortex-export-db-file` | | `VORTEX_EXPORT_DB_IMAGE` | Name of the database container image to use. Uncomment to use an image with a DB data loaded into it. @see https://github.com/drevops/mariadb-drupal-data to seed your DB image. | `${VORTEX_DB_IMAGE}` | `.vortex/tooling/src/vortex-export-db`, `.vortex/tooling/src/vortex-export-db-image`, `.vortex/tooling/src/vortex-push-db-image` | | `VORTEX_EXPORT_DB_IMAGE_ARCHIVE_FILE` | Container image archive file name. | `UNDEFINED` | `.vortex/tooling/src/vortex-export-db-image` | | `VORTEX_EXPORT_DB_IMAGE_DIR` | Directory with database image archive file. | `${VORTEX_DB_DIR}` | `.vortex/tooling/src/vortex-export-db-image` | diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php index 5bada4432..9e3b3dba5 100644 --- a/.vortex/installer/src/Command/InstallCommand.php +++ b/.vortex/installer/src/Command/InstallCommand.php @@ -217,8 +217,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int label: 'Downloading Vortex', action: function (): string { $release_prefix = Version::releasePrefix($this->getApplication()->getVersion()); + // The staging directory can be pointed at a reused location, and the + // download unpacks into it rather than replacing it, so anything a + // previous run left behind would be treated as shipped by this one. + $this->fileManager->resetStaging(); $version = $this->getRepositoryDownloader()->download($this->artifact, $this->config->get(Config::TMP), $release_prefix); $this->config->set(Config::VERSION, $version); + $this->fileManager->snapshotTemplate(); + $this->fileManager->snapshotPreviousTemplate($this->getRepositoryDownloader(), $this->artifact); return $version; }, hint: fn(): string => sprintf('Downloading from "%s" repository at ref "%s"', $this->artifact->getRepo(), $this->artifact->getRef()), diff --git a/.vortex/installer/src/Utils/FileManager.php b/.vortex/installer/src/Utils/FileManager.php index 1706d7e50..38d4b3169 100644 --- a/.vortex/installer/src/Utils/FileManager.php +++ b/.vortex/installer/src/Utils/FileManager.php @@ -4,17 +4,112 @@ namespace DrevOps\VortexInstaller\Utils; +use DrevOps\VortexInstaller\Downloader\Artifact; use DrevOps\VortexInstaller\Downloader\Downloader; +use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; /** * File operations for the installation process. */ class FileManager { + /** + * Path of the template's own harness, which is never shipped. + */ + const HARNESS_DIR = '.vortex'; + + /** + * Paths shipped by the downloaded template, relative to its root. + * + * @var array + */ + protected array $templatePaths = []; + + /** + * Name of the file recording what the installer wrote into the project. + */ + const MANIFEST_FILE = '.vortex-manifest.json'; + + /** + * Algorithm used to detect project edits to shipped files. + */ + const HASH_ALGO = 'sha256'; + + /** + * Content hashes shipped by the version the project currently runs. + * + * @var array + */ + protected array $previousTemplateHashes = []; + public function __construct( protected Config $config, ) {} + /** + * Empty the staging directory the template is downloaded into. + */ + public function resetStaging(): void { + $dir = $this->config->get(Config::TMP); + + File::remove($dir); + File::mkdir($dir); + } + + /** + * Record the paths of the freshly downloaded template. + * + * Taken before the handlers process the staged copy, so that whatever they + * remove can later be identified as the paths the selection excludes. + */ + public function snapshotTemplate(): void { + $this->templatePaths = $this->relativePaths($this->config->get(Config::TMP)); + } + + /** + * Record the paths shipped by the version the project currently runs. + * + * A path the template has stopped shipping altogether is absent from the + * incoming download, so the selection diff alone cannot see it. Listing the + * project's own version restores it as a candidate, which is what makes a + * file dropped between releases removable rather than permanent. + * + * Failure is not fatal: the recorded reference may no longer resolve, in + * which case only the selection diff applies. + * + * @param \DrevOps\VortexInstaller\Downloader\RepositoryDownloader $downloader + * The repository downloader. + * @param \DrevOps\VortexInstaller\Downloader\Artifact $artifact + * The artifact identifying the repository to read the reference from. + */ + public function snapshotPreviousTemplate(RepositoryDownloader $downloader, Artifact $artifact): void { + if (!$this->config->isVortexProject()) { + return; + } + + $ref = Version::detectProjectRef((string) $this->config->getDestination()); + + if ($ref === NULL) { + return; + } + + $dir = $this->config->get(Config::TMP) . '-previous'; + + try { + // The extraction unpacks into an existing directory. + File::remove($dir); + File::mkdir($dir); + $downloader->download(Artifact::create($artifact->getRepo(), $ref), $dir); + $this->previousTemplateHashes = $this->hashDirectory($dir); + } + catch (\Exception) { + $this->previousTemplateHashes = []; + } + finally { + File::remove($dir); + } + } + /** * Prepare the destination directory. * @@ -50,6 +145,19 @@ public function copyFiles(): void { $src = $this->config->get(Config::TMP); $destination = $this->config->getDestination(); + // What the project should hold for a path this install no longer ships. + // The manifest is authoritative because it records the processed content + // that was actually written; the previous version's own files stand in for + // projects installed before manifests existed, and match only where the + // installer copied the file through unchanged. + $expected = $this->previousTemplateHashes; + $expected = $this->readManifest() + $expected; + + // Anything either version of the template ships, or the last install + // wrote, but the staged copy no longer holds. + $shipped = array_merge(array_keys($expected), $this->templatePaths); + $excluded = array_diff($shipped, $this->relativePaths($src)); + // Symlink ordering prevents copying files one-by-one into the destination // directory. Instead, all ignored files and empty directories are removed // to make the src directory "clean", and then the whole directory is @@ -87,7 +195,153 @@ public function copyFiles(): void { File::copy($destination . '/.env.local.example', $destination . '/.env.local'); } + $this->removeExcludedPaths($excluded, $expected); $this->removeObsoletePaths(); + $this->writeManifest($src); + } + + /** + * Remove paths the install no longer ships from the destination. + * + * The staged copy is overlaid onto the destination without a delete pass, so + * a path this install drops would otherwise survive and keep being detected + * as an active feature. A path is only removed when the project's copy still + * matches what the template put there: a project that edited the file owns + * it, and an edit that cannot be ruled out is treated as one. Only projects + * already running Vortex are pruned at all. + * + * @param array $paths + * Template-relative paths absent from the staged copy. + * @param array $expected + * Content hashes the template last wrote, keyed by path. + */ + protected function removeExcludedPaths(array $paths, array $expected): void { + if (!$this->config->isVortexProject()) { + return; + } + + $destination = $this->config->getDestination(); + $dirs = []; + + foreach ($paths as $path) { + // The harness never ships, so a matching path in the destination is the + // project's own. + if ($path === self::HARNESS_DIR || str_starts_with($path, self::HARNESS_DIR . '/')) { + continue; + } + + $target = $destination . '/' . $path; + + if (!is_file($target)) { + continue; + } + + // Without a recorded hash there is nothing to compare the project's copy + // against, so ownership cannot be established. + if (!isset($expected[$path]) || hash_file(self::HASH_ALGO, $target) !== $expected[$path]) { + continue; + } + + File::remove($target); + + for ($dir = dirname($path); $dir !== '.'; $dir = dirname($dir)) { + $dirs[$dir] = substr_count($dir, '/'); + } + } + + // Deepest first, so a parent is only tested once its children are gone. + arsort($dirs); + + foreach (array_keys($dirs) as $dir) { + File::rmdirIfEmpty($destination . '/' . $dir); + } + } + + /** + * Record what this install wrote, so the next one can detect project edits. + * + * The staged copy at this point holds exactly the processed content that was + * copied into the destination, which is what a later run has to compare the + * project's files against. + * + * @param string $src + * The staged template directory. + */ + protected function writeManifest(string $src): void { + $hashes = $this->hashDirectory($src); + + if ($hashes === []) { + return; + } + + ksort($hashes); + + File::dump($this->config->getDestination() . '/' . self::MANIFEST_FILE, json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + /** + * Read the hashes recorded by the previous install. + * + * @return array + * Content hashes keyed by path, empty when the project has no manifest. + */ + protected function readManifest(): array { + $file = $this->config->getDestination() . '/' . self::MANIFEST_FILE; + + if (!is_file($file)) { + return []; + } + + $data = json_decode((string) file_get_contents($file), TRUE); + + if (!is_array($data)) { + return []; + } + + return array_filter($data, fn(mixed $hash, mixed $path): bool => is_string($path) && is_string($hash), ARRAY_FILTER_USE_BOTH); + } + + /** + * Hash every file within a directory, keyed by its relative path. + * + * @param string $directory + * Directory to scan. + * + * @return array + * Content hashes keyed by relative path. + */ + protected function hashDirectory(string $directory): array { + $hashes = []; + + foreach ($this->relativePaths($directory) as $path) { + $file = $directory . '/' . $path; + + if (is_file($file)) { + $hashes[$path] = (string) hash_file(self::HASH_ALGO, $file); + } + } + + return $hashes; + } + + /** + * List the files within a directory, relative to it. + * + * @param string $directory + * Directory to scan. + * + * @return array + * Relative file paths. + */ + protected function relativePaths(string $directory): array { + if (!is_dir($directory)) { + return []; + } + + $root = File::dir($directory); + $files = File::scandir($root, File::ignoredPaths()); + + return array_map(fn(string $file): string => ltrim(str_replace($root, '', $file), DIRECTORY_SEPARATOR), $files); } /** diff --git a/.vortex/installer/src/Utils/Version.php b/.vortex/installer/src/Utils/Version.php index 41f5ed342..0e17bbfdf 100644 --- a/.vortex/installer/src/Utils/Version.php +++ b/.vortex/installer/src/Utils/Version.php @@ -68,6 +68,38 @@ public static function majorFromConstraint(?string $constraint): ?int { return preg_match('/(\d+)/', $constraint, $matches) ? (int) $matches[1] : NULL; } + /** + * Detect the Vortex reference an installed project was last installed from. + * + * The README badge records the reference of the installed release, which is + * the only place the exact version is preserved: composer.json pins the + * tooling package's major rather than the template's version. Shields.io + * escapes a literal dash in the label as a double dash. + * + * @param string $dir + * The project directory. + * + * @return string|null + * The git reference, or NULL when the badge is absent or unreadable. + */ + public static function detectProjectRef(string $dir): ?string { + $readme = $dir . '/README.md'; + + if (!is_file($readme)) { + return NULL; + } + + $contents = (string) file_get_contents($readme); + + if (!preg_match('#badge/Vortex-(.+?)-65ACBC\.svg#', $contents, $matches)) { + return NULL; + } + + $ref = str_replace('--', '-', $matches[1]); + + return Validator::isGitRef($ref) ? $ref : NULL; + } + /** * Detect the Vortex major of an installed project from its composer.json. * diff --git a/.vortex/installer/tests/Fixtures/handler_process/_baseline/.ignorecontent b/.vortex/installer/tests/Fixtures/handler_process/_baseline/.ignorecontent index 660027cf5..cf616ec98 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/_baseline/.ignorecontent +++ b/.vortex/installer/tests/Fixtures/handler_process/_baseline/.ignorecontent @@ -5,3 +5,4 @@ yarn.lock node_modules vendor .env.local +.vortex-manifest.json diff --git a/.vortex/installer/tests/Functional/Command/InstallExcludedPathsTest.php b/.vortex/installer/tests/Functional/Command/InstallExcludedPathsTest.php new file mode 100644 index 000000000..9fd804deb --- /dev/null +++ b/.vortex/installer/tests/Functional/Command/InstallExcludedPathsTest.php @@ -0,0 +1,177 @@ + $contents) { + File::dump(static::$sut . '/' . $path, $contents); + } + + if ($recorded !== []) { + $hashes = array_map(fn(string $contents): string => hash('sha256', $contents), $recorded); + File::dump(static::$sut . '/' . FileManager::MANIFEST_FILE, (string) json_encode($hashes, JSON_PRETTY_PRINT)); + } + + if ($is_vortex_project) { + File::dump(static::$sut . '/README.md', '[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-65ACBC.svg)](https://github.com/drevops/vortex)'); + } + + $this->runInstall($prompts); + + foreach ($absent as $path) { + $this->assertFileDoesNotExist(static::$sut . '/' . $path, sprintf('Path "%s" removed from the destination.', $path)); + } + + foreach ($present as $path => $contents) { + $this->assertFileExists(static::$sut . '/' . $path, sprintf('Path "%s" kept in the destination.', $path)); + + if ($contents !== NULL) { + $this->assertStringEqualsFile(static::$sut . '/' . $path, $contents, sprintf('Path "%s" kept its contents.', $path)); + } + } + } + + public static function dataProviderExcludedPaths(): \Iterator { + $shipped = [ + 'phpstan.neon' => 'parameters: []', + 'phpunit.xml' => '', + 'jest.config.js' => 'module.exports = {};', + 'tests/phpunit/bootstrap.php' => ' [ + TRUE, + $shipped, + $shipped, + self::PROMPTS_WITHOUT_TEST_TOOLS, + [ + 'phpstan.neon', + 'phpunit.xml', + 'jest.config.js', + 'tests/phpunit/bootstrap.php', + ], + [ + // A tool that stayed selected keeps its shipped configuration. + 'phpcs.xml' => NULL, + 'behat.yml' => NULL, + ], + ]; + yield 'modified excluded paths kept with their contents' => [ + TRUE, + [ + 'phpstan.neon' => "parameters:\n level: 8", + 'phpunit.xml' => '', + ], + $shipped, + self::PROMPTS_WITHOUT_TEST_TOOLS, + [ + // Unmodified, so still removed. + 'phpunit.xml', + ], + [ + 'phpstan.neon' => "parameters:\n level: 8", + ], + ]; + yield 'excluded paths kept when nothing was recorded' => [ + TRUE, + $shipped, + [], + self::PROMPTS_WITHOUT_TEST_TOOLS, + [], + [ + 'phpstan.neon' => 'parameters: []', + 'jest.config.js' => 'module.exports = {};', + ], + ]; + yield 'project-authored paths kept' => [ + TRUE, + [ + 'custom-notes.md' => 'Project notes.', + 'scripts/custom-deploy.sh' => 'echo deploy', + 'web/modules/custom/mymodule/mymodule.info.yml' => 'name: My module', + 'web/modules/custom/mymodule/js/mymodule.test.js' => "test('kept', () => {});", + ], + $shipped, + self::PROMPTS_WITHOUT_TEST_TOOLS, + [], + [ + // Never shipped by the template, so never a candidate for removal. + 'custom-notes.md' => 'Project notes.', + 'scripts/custom-deploy.sh' => 'echo deploy', + 'web/modules/custom/mymodule/mymodule.info.yml' => 'name: My module', + // Matched only by a glob over project content, not by a shipped path. + 'web/modules/custom/mymodule/js/mymodule.test.js' => "test('kept', () => {});", + ], + ]; + yield 'harness paths kept' => [ + TRUE, + ['.vortex/CLAUDE.md' => 'Project owned.'], + ['.vortex/CLAUDE.md' => 'Project owned.'], + self::PROMPTS_WITHOUT_TEST_TOOLS, + [], + [ + // The harness is stripped unconditionally rather than by selection. + '.vortex/CLAUDE.md' => 'Project owned.', + ], + ]; + yield 'nothing removed from a destination that is not a Vortex project' => [ + FALSE, + $shipped, + $shipped, + self::PROMPTS_WITHOUT_TEST_TOOLS, + [], + [ + 'phpstan.neon' => 'parameters: []', + 'jest.config.js' => 'module.exports = {};', + ], + ]; + } + + /** + * Run a non-interactive install into the system under test. + */ + protected function runInstall(string $prompts): void { + $executable_finder = $this->createMock(ExecutableFinder::class); + $executable_finder->method('find')->willReturnCallback(fn(string $command): string => '/usr/bin/' . $command); + + $install_command = new InstallCommand(); + $install_command->setExecutableFinder($executable_finder); + + static::applicationInitFromCommand($install_command); + + Env::put(Config::IS_DEMO_DB_FETCH_SKIP, '1'); + + $this->applicationRun([ + '--' . InstallCommand::OPTION_NO_INTERACTION => TRUE, + '--' . InstallCommand::OPTION_URI => File::dir(static::$root), + '--' . InstallCommand::OPTION_DESTINATION => static::$sut, + '--' . InstallCommand::OPTION_PROMPTS => $prompts, + ]); + } + +} diff --git a/.vortex/installer/tests/Unit/Utils/FileManagerTest.php b/.vortex/installer/tests/Unit/Utils/FileManagerTest.php index 5d2f99b69..4f5cb034f 100644 --- a/.vortex/installer/tests/Unit/Utils/FileManagerTest.php +++ b/.vortex/installer/tests/Unit/Utils/FileManagerTest.php @@ -7,6 +7,7 @@ use DrevOps\VortexInstaller\Downloader\Downloader; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; use DrevOps\VortexInstaller\Utils\Config; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Utils\FileManager; use PHPUnit\Framework\Attributes\CoversClass; @@ -160,6 +161,157 @@ public function testCopyFilesHandlesEmptySrc(): void { $this->addToAssertionCount(1); } + public function testCopyFilesRemovesUnmodifiedExcludedPaths(): void { + $src = self::$sut . '/src_excluded'; + $destination = self::$sut . '/dst_excluded'; + file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + file_put_contents($src . '/phpstan.neon', 'parameters: []'); + file_put_contents(File::mkdir($src . '/.circleci') . '/config.yml', 'version: 2.1'); + + $config = new Config('/tmp/root', $destination, $src); + $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); + $fm = new FileManager($config); + + // A previous install wrote both, unmodified since. + file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'parameters: []'); + file_put_contents(File::mkdir($destination . '/.circleci') . '/config.yml', 'version: 2.1'); + $this->stubManifest($destination, [ + 'phpstan.neon' => 'parameters: []', + '.circleci/config.yml' => 'version: 2.1', + ]); + + $fm->snapshotTemplate(); + + // The current selection drops them from the staged copy. + File::remove($src . '/phpstan.neon'); + File::remove($src . '/.circleci'); + + $fm->copyFiles(); + + $this->assertFileDoesNotExist($destination . '/phpstan.neon', 'Unmodified excluded file removed from the destination.'); + $this->assertFileDoesNotExist($destination . '/.circleci/config.yml', 'Unmodified excluded directory contents removed.'); + $this->assertDirectoryDoesNotExist($destination . '/.circleci', 'Directory emptied by the removal is pruned.'); + $this->assertFileExists($destination . '/composer.json', 'Shipped files still copied.'); + } + + public function testCopyFilesKeepsModifiedExcludedPaths(): void { + $src = self::$sut . '/src_modified'; + $destination = self::$sut . '/dst_modified'; + file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + file_put_contents($src . '/phpstan.neon', 'parameters: []'); + + $config = new Config('/tmp/root', $destination, $src); + $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); + $fm = new FileManager($config); + + // The project edited the file after the previous install wrote it. + file_put_contents(File::mkdir($destination) . '/phpstan.neon', "parameters:\n level: 8"); + $this->stubManifest($destination, ['phpstan.neon' => 'parameters: []']); + + $fm->snapshotTemplate(); + File::remove($src . '/phpstan.neon'); + + $fm->copyFiles(); + + $this->assertFileExists($destination . '/phpstan.neon', 'A file the project edited is never removed.'); + $this->assertStringEqualsFile($destination . '/phpstan.neon', "parameters:\n level: 8", 'The project edit is left untouched.'); + } + + public function testCopyFilesKeepsExcludedPathsWithoutRecordedHash(): void { + $src = self::$sut . '/src_unverifiable'; + $destination = self::$sut . '/dst_unverifiable'; + file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + file_put_contents($src . '/phpstan.neon', 'parameters: []'); + + $config = new Config('/tmp/root', $destination, $src); + $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); + $fm = new FileManager($config); + $fm->snapshotTemplate(); + + // No manifest and no previous version, so ownership cannot be established. + file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'parameters: []'); + File::remove($src . '/phpstan.neon'); + + $fm->copyFiles(); + + $this->assertFileExists($destination . '/phpstan.neon', 'Without a recorded hash the file is left alone.'); + } + + public function testCopyFilesWritesTheManifest(): void { + $src = self::$sut . '/src_manifest'; + $destination = self::$sut . '/dst_manifest'; + file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + file_put_contents(File::mkdir($src . '/scripts') . '/provision.sh', 'echo 1'); + + $config = new Config('/tmp/root', $destination, $src); + $fm = new FileManager($config); + $fm->snapshotTemplate(); + + $fm->copyFiles(); + + $manifest = json_decode((string) file_get_contents($destination . '/' . FileManager::MANIFEST_FILE), TRUE); + + $this->assertIsArray($manifest); + $this->assertArrayHasKey('scripts/provision.sh', $manifest, 'Manifest records every shipped path.'); + $this->assertEquals(hash('sha256', 'echo 1'), $manifest['scripts/provision.sh'], 'Manifest records the content that was written.'); + } + + public function testCopyFilesKeepsPathsTheTemplateNeverShipped(): void { + $src = self::$sut . '/src_unknown'; + $destination = self::$sut . '/dst_unknown'; + file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + + $config = new Config('/tmp/root', $destination, $src); + $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); + $fm = new FileManager($config); + $fm->snapshotTemplate(); + + file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'project owned'); + file_put_contents(File::mkdir($destination . '/web/modules/custom/mymodule') . '/mymodule.info.yml', 'name: My module'); + + $fm->copyFiles(); + + $this->assertFileExists($destination . '/phpstan.neon', 'A path the template never shipped is left alone.'); + $this->assertFileExists($destination . '/web/modules/custom/mymodule/mymodule.info.yml', 'Project-authored content is left alone.'); + } + + public function testCopyFilesKeepsExcludedPathsForNonVortexProject(): void { + $src = self::$sut . '/src_fresh'; + $destination = self::$sut . '/dst_fresh'; + file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + file_put_contents($src . '/phpstan.neon', 'parameters: []'); + + $config = new Config('/tmp/root', $destination, $src); + $fm = new FileManager($config); + $fm->snapshotTemplate(); + + file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'project owned'); + File::remove($src . '/phpstan.neon'); + + $fm->copyFiles(); + + $this->assertFileExists($destination . '/phpstan.neon', 'A destination that is not a Vortex project is never pruned.'); + } + + public function testCopyFilesKeepsHarnessPaths(): void { + $src = self::$sut . '/src_harness'; + $destination = self::$sut . '/dst_harness'; + file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + file_put_contents(File::mkdir($src . '/.vortex') . '/CLAUDE.md', 'harness'); + + $config = new Config('/tmp/root', $destination, $src); + $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); + $fm = new FileManager($config); + $fm->snapshotTemplate(); + + file_put_contents(File::mkdir($destination . '/.vortex') . '/CLAUDE.md', 'project owned'); + File::remove($src . '/.vortex'); + + $fm->copyFiles(); + + $this->assertFileExists($destination . '/.vortex/CLAUDE.md', "The harness never ships, so a matching path is the project's own."); + } + public function testCopyFilesRemovesObsoleteScriptsVortex(): void { // Simulate an upgrade from a Vortex version that shipped scripts at // 'scripts/vortex/' before they were extracted into the @@ -196,6 +348,20 @@ public function testRemoveObsoletePathsSilentOnMissing(): void { $this->addToAssertionCount(1); } + /** + * Write a manifest recording what a previous install wrote. + * + * @param string $destination + * The project directory. + * @param array $files + * Content the previous install wrote, keyed by relative path. + */ + protected function stubManifest(string $destination, array $files): void { + $hashes = array_map(fn(string $contents): string => hash('sha256', $contents), $files); + + File::dump($destination . '/' . FileManager::MANIFEST_FILE, (string) json_encode($hashes, JSON_PRETTY_PRINT)); + } + /** * Tests for prepareDemo(). */ diff --git a/.vortex/tests/phpunit/Functional/InstallerTest.php b/.vortex/tests/phpunit/Functional/InstallerTest.php index 2dffadab5..a1c43b51d 100644 --- a/.vortex/tests/phpunit/Functional/InstallerTest.php +++ b/.vortex/tests/phpunit/Functional/InstallerTest.php @@ -87,6 +87,120 @@ public function testInstallFromLatest(): void { $this->gitAssertNotClean(static::$sut, 'Git working tree should not be clean after Vortex update'); } + #[Group('p3')] + public function testUpdateRemovesUnmodifiedFilesDroppedByTemplate(): void { + $commit_with_script = $this->addLegacyScriptToTemplate(); + + $this->logSubstep('Install the SUT from the version that ships the script'); + $this->installSutFrom($commit_with_script); + $this->assertFileExists('scripts/provision-50-legacy.sh', 'Template-owned script installed into the SUT'); + $this->assertFileExists('.vortex-manifest.json', 'Install records what it wrote'); + $this->gitCommitAll(static::$sut, 'Init Vortex'); + + $commit_without_script = $this->dropLegacyScriptFromTemplate(); + + $this->logSubstep('Update the SUT to the version that no longer ships the script'); + $this->runInstaller([sprintf('--uri=%s#%s', static::$repo, $commit_without_script)]); + + $this->assertFileDoesNotExist('scripts/provision-50-legacy.sh', 'Unmodified script dropped by the template removed from the SUT'); + $this->assertFileExists('scripts/provision-40-example.sh', 'Scripts still shipped by the template kept in the SUT'); + $this->assertFileExists('scripts/README.md', 'Sibling shipped files kept in the SUT'); + } + + #[Group('p3')] + public function testUpdateKeepsModifiedFilesDroppedByTemplate(): void { + $commit_with_script = $this->addLegacyScriptToTemplate(); + + $this->logSubstep('Install the SUT from the version that ships the script'); + $this->installSutFrom($commit_with_script); + $this->gitCommitAll(static::$sut, 'Init Vortex'); + + $this->logSubstep('Modify the script in the SUT, as a project would'); + $modified = "#!/usr/bin/env bash\necho 'Customised by the project.'\n"; + File::dump(static::$sut . '/scripts/provision-50-legacy.sh', $modified); + $this->gitCommitAll(static::$sut, 'Customised the provision script'); + + $commit_without_script = $this->dropLegacyScriptFromTemplate(); + + $this->logSubstep('Update the SUT to the version that no longer ships the script'); + $this->runInstaller([sprintf('--uri=%s#%s', static::$repo, $commit_without_script)]); + + $this->assertFileExists('scripts/provision-50-legacy.sh', 'A script the project modified is never removed'); + $this->assertFileContainsString('scripts/provision-50-legacy.sh', 'Customised by the project.', 'The project modification is left untouched'); + } + + #[Group('p3')] + public function testUpdateKeepsProjectAuthoredFiles(): void { + $commit_with_script = $this->addLegacyScriptToTemplate(); + + $this->logSubstep('Install the SUT from the version that ships the script'); + $this->installSutFrom($commit_with_script); + + $this->logSubstep('Add project-authored files where projects extend Vortex'); + $project_files = [ + 'scripts/custom-deploy.sh' => "#!/usr/bin/env bash\necho 'Project deploy.'\n", + '.docker/custom.dockerfile' => "FROM alpine\n", + '.github/workflows/custom.yml' => "name: Custom\n", + '.circleci/custom.yml' => "version: 2.1\n", + 'config/custom.settings.yml' => "custom: true\n", + 'recipes/custom/recipe.yml' => "name: Custom recipe\n", + '.claude/skills/custom/SKILL.md' => "# Custom skill\n", + 'PROJECT-NOTES.md' => "Project notes.\n", + ]; + foreach ($project_files as $path => $contents) { + File::dump(static::$sut . '/' . $path, $contents); + } + $this->gitCommitAll(static::$sut, 'Init Vortex with project files'); + + $commit_without_script = $this->dropLegacyScriptFromTemplate(); + + $this->logSubstep('Update the SUT to the version that no longer ships the script'); + $this->runInstaller([sprintf('--uri=%s#%s', static::$repo, $commit_without_script)]); + + $this->assertFileDoesNotExist('scripts/provision-50-legacy.sh', 'The template-owned script is still removed'); + + foreach ($project_files as $path => $contents) { + $this->assertFileExists($path, sprintf('Project-authored "%s" kept in the SUT', $path)); + $this->assertFileContainsString($path, trim($contents), sprintf('Project-authored "%s" kept its contents', $path)); + } + } + + /** + * Add a template-owned script to the template repository. + */ + protected function addLegacyScriptToTemplate(): string { + $this->logSubstep('Add a template-owned script to the Vortex template repository'); + File::dump(static::$repo . '/scripts/provision-50-legacy.sh', "#!/usr/bin/env bash\necho 'Legacy provision step.'\n"); + $commit = $this->gitCommitAll(static::$repo, 'Added a legacy provision script to Vortex'); + $this->logNote(sprintf('Vortex version with the script: %s', $commit)); + + return $commit; + } + + /** + * Drop the template-owned script from the template repository. + */ + protected function dropLegacyScriptFromTemplate(): string { + $this->logSubstep('Drop the script from the Vortex template repository'); + File::remove(static::$repo . '/scripts/provision-50-legacy.sh'); + $commit = $this->gitCommitAll(static::$repo, 'Removed the legacy provision script from Vortex'); + $this->logNote(sprintf('Vortex version without the script: %s', $commit)); + + return $commit; + } + + /** + * Install the SUT from a given template reference. + */ + protected function installSutFrom(string $ref): void { + $this->gitInitRepo(static::$sut); + static::$sutInstallerEnv = [ + 'VORTEX_INSTALLER_TEMPLATE_REPO' => FALSE, + 'SHELL_VERBOSITY' => FALSE, + ]; + $this->runInstaller([sprintf('--uri=%s#%s', static::$repo, $ref)]); + } + #[Group('p3')] public function testInstallFromRef(): void { $this->logSubstep('Add custom files to SUT');