diff --git a/src/Console/Command/PurgeCommand.php b/src/Console/Command/PurgeCommand.php
index bbef3d2..548c46a 100644
--- a/src/Console/Command/PurgeCommand.php
+++ b/src/Console/Command/PurgeCommand.php
@@ -77,7 +77,7 @@ class PurgeCommand extends BaseCommand
}
$packageSelection = new PackageSelection($output, $outputDir, $config, false);
- $packages = $packageSelection->load();
+ $packages = $packageSelection->load(true);
$satis_homepage = getenv('SATIS_HOMEPAGE');
$prefix = sprintf(
diff --git a/src/PackageSelection/PackageSelection.php b/src/PackageSelection/PackageSelection.php
index 552c425..ddadf76 100644
--- a/src/PackageSelection/PackageSelection.php
+++ b/src/PackageSelection/PackageSelection.php
@@ -293,7 +293,7 @@ class PackageSelection
/**
* @return PackageInterface[]
*/
- public function load(): array
+ public function load(bool $failOnMissingInclude = false): array
{
$packages = [];
$rootJsonFile = new JsonFile($this->filename);
@@ -330,11 +330,17 @@ class PackageSelection
$includedJsonFile = new JsonFile($dirname . '/' . $file);
if (!$includedJsonFile->exists()) {
- $this->output->writeln(sprintf(
- 'File \'%s\' does not exist, defined in "includes" in \'%s\'',
+ $message = sprintf(
+ 'File \'%s\' does not exist, defined in "includes" in \'%s\'',
$includedJsonFile->getPath(),
$rootJsonFile->getPath()
- ));
+ );
+
+ if ($failOnMissingInclude) {
+ throw new \RuntimeException($message);
+ }
+
+ $this->output->writeln('' . $message . '');
continue;
}
diff --git a/tests/Console/Command/PurgeCommandTest.php b/tests/Console/Command/PurgeCommandTest.php
index 7894b6f..cf4e35c 100644
--- a/tests/Console/Command/PurgeCommandTest.php
+++ b/tests/Console/Command/PurgeCommandTest.php
@@ -534,6 +534,55 @@ class PurgeCommandTest extends TestCase
self::assertStringContainsString('Removed empty directory', $tester->getDisplay());
}
+ /**
+ * A concurrent build can rewrite packages.json and drop the include it points to;
+ * the archives listed in that include are still needed and must not be purged.
+ */
+ #[TestDox('execute() aborts without removing archives when an included file is missing')]
+ public function testExecuteAbortsWhenIncludedFileIsMissing(): void
+ {
+ $outputDir = vfsStream::url('satis/output-missing-include');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir . '/vendor', 0755, true);
+
+ $archiveFile = $archiveDir . '/vendor/package.tar.gz';
+ file_put_contents($archiveFile, 'a');
+
+ $includeFile = 'include/all$missing.json';
+ $this->writeOutputRepository($outputDir, $this->packageEntry(
+ 'vendor/package',
+ '1.0.0',
+ 'https://example.com/dist/vendor/package.tar.gz'
+ ), $includeFile);
+ unlink($outputDir . '/' . $includeFile);
+
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://example.com',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $exception = null;
+
+ try {
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+ } catch (\RuntimeException $exception) {
+ }
+
+ self::assertInstanceOf(\RuntimeException::class, $exception);
+ self::assertStringContainsString($includeFile . '\' does not exist', $exception->getMessage());
+ self::assertFileExists($archiveFile);
+ }
+
// --- Dry-run ---
#[TestDox('execute() does not remove archives or empty dirs when dry-run is enabled')]