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', '[](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');