diff --git a/src/Console/Command/PurgeCommand.php b/src/Console/Command/PurgeCommand.php
new file mode 100644
index 0000000..bbef3d2
--- /dev/null
+++ b/src/Console/Command/PurgeCommand.php
@@ -0,0 +1,179 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace Composer\Satis\Console\Command;
+
+use Composer\Command\BaseCommand;
+use Composer\Json\JsonFile;
+use Composer\Satis\PackageSelection\PackageSelection;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\Finder\SplFileInfo;
+
+class PurgeCommand extends BaseCommand
+{
+ protected function configure(): void
+ {
+ $this->getName() ?? $this->setName('purge');
+ $this
+ ->setDescription('Purge packages')
+ ->setDefinition([
+ new InputArgument('file', InputArgument::OPTIONAL, 'Json file to use', './satis.json'),
+ new InputArgument('output-dir', InputArgument::OPTIONAL, 'Location where to output built files', null),
+ new InputArgument('dry-run', InputArgument::OPTIONAL, 'Dry run, allows to inspect what might be deleted', null),
+ ])
+ ->setHelp(
+ <<<'EOT'
+ The purge command deletes useless archive files, depending
+ on given json file (satis.json is used by default) and the
+ newest json file in the include directory of the given output-dir.
+
+ In your satis.json (or other name you give), you must define
+ "archive" argument. You also need to define "homepage" argument or "SATIS_HOMEPAGE" environment variable if you don't use archive "prefix-url" argument.
+ EOT
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $configFile = $input->getArgument('file');
+ $file = new JsonFile($configFile);
+ if (!$file->exists()) {
+ $output->writeln('File not found: ' . $configFile . '');
+
+ return 1;
+ }
+ $config = $file->read();
+
+ /*
+ * Check whether archive is defined
+ */
+ if (!isset($config['archive']) || !isset($config['archive']['directory'])) {
+ $output->writeln('You must define "archive" parameter in your ' . $configFile . '');
+
+ return 1;
+ }
+
+ $outputDir = $input->getArgument('output-dir') ?? $config['output-dir'] ?? null;
+ if (null === $outputDir) {
+ throw new \InvalidArgumentException('The output dir must be specified as second argument or be configured inside ' . $input->getArgument('file'));
+ }
+
+ $dryRun = (bool) $input->getArgument('dry-run');
+ if ($dryRun) {
+ $output->writeln('Dry run enabled, no actual changes will be done.');
+ }
+
+ $packageSelection = new PackageSelection($output, $outputDir, $config, false);
+ $packages = $packageSelection->load();
+
+ $satis_homepage = getenv('SATIS_HOMEPAGE');
+ $prefix = sprintf(
+ '%s/%s/',
+ $config['archive']['prefix-url'] ?? (false !== $satis_homepage ? $satis_homepage : $config['homepage']),
+ $config['archive']['directory']
+ );
+
+ $length = strlen($prefix);
+ $needed = [];
+ foreach ($packages as $package) {
+ if (is_null($package->getDistType())) {
+ continue;
+ }
+ $url = (string) $package->getDistUrl();
+ if (substr($url, 0, $length) === $prefix) {
+ $needed[] = substr($url, $length);
+ }
+ }
+
+ $distDirectory = sprintf('%s/%s', $outputDir, $config['archive']['directory']);
+
+ $finder = new Finder();
+ $finder
+ ->files()
+ ->in($distDirectory)
+ ;
+
+ if (0 === $finder->count()) {
+ $output->writeln('No archives found.');
+
+ return 0;
+ }
+
+ /** @var SplFileInfo[] $unreferenced */
+ $unreferenced = [];
+ foreach ($finder as $currentFile) {
+ $filename = strtr($currentFile->getRelativePathname(), DIRECTORY_SEPARATOR, '/');
+ if (!in_array($filename, $needed, true)) {
+ $unreferenced[] = $currentFile;
+ }
+ }
+
+ if (0 === count($unreferenced)) {
+ $output->writeln('No unreferenced archives found.');
+
+ return 0;
+ }
+
+ foreach ($unreferenced as $currentFile) {
+ if (!$dryRun) {
+ unlink($currentFile->getPathname());
+ }
+
+ $output->writeln(sprintf(
+ 'Removed archive: %s',
+ $currentFile->getRelativePathname()
+ ));
+ }
+
+ if (!$dryRun) {
+ $this->removeEmptyDirectories($output, $distDirectory);
+ }
+
+ $output->writeln('Done.');
+
+ return 0;
+ }
+
+ private function removeEmptyDirectories(OutputInterface $output, string $dir, int $depth = 2): bool
+ {
+ $empty = true;
+ $children = @scandir($dir);
+
+ if (false === $children) {
+ return false;
+ }
+
+ foreach ($children as $child) {
+ if ('.' === $child || '..' === $child) {
+ continue;
+ }
+
+ $path = $dir . DIRECTORY_SEPARATOR . $child;
+
+ if (is_dir($path)
+ && $depth > 0
+ && $this->removeEmptyDirectories($output, $path, $depth - 1)
+ && rmdir($path)
+ ) {
+ $output->writeln(sprintf('Removed empty directory: %s', $path));
+ } else {
+ $empty = false;
+ }
+ }
+
+ return $empty;
+ }
+}
diff --git a/tests/Console/Command/PurgeCommandTest.php b/tests/Console/Command/PurgeCommandTest.php
new file mode 100644
index 0000000..7894b6f
--- /dev/null
+++ b/tests/Console/Command/PurgeCommandTest.php
@@ -0,0 +1,645 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace Composer\Satis\Console\Command;
+
+use Composer\Console\Application;
+use org\bovigo\vfs\vfsStream;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Output\BufferedOutput;
+use Symfony\Component\Console\Output\NullOutput;
+use Symfony\Component\Console\Tester\CommandTester;
+
+#[TestDox('PurgeCommand')]
+#[CoversClass(PurgeCommand::class)]
+class PurgeCommandTest extends TestCase
+{
+ private string $configPath;
+
+ protected function setUp(): void
+ {
+ vfsStream::setup('satis');
+ $this->configPath = vfsStream::url('satis/satis.json');
+ }
+
+ // --- Helpers ---
+
+ private function createTester(): CommandTester
+ {
+ $app = new Application();
+ $app->addCommand(new PurgeCommand());
+
+ return new CommandTester($app->find('purge'));
+ }
+
+ /**
+ * @param array $config
+ */
+ private function writeSatisConfig(array $config): void
+ {
+ file_put_contents($this->configPath, json_encode($config, JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * @param array>> $packagesByName
+ */
+ private function writeOutputRepository(string $outputDir, array $packagesByName, string $includeFile = 'include/packages.json'): void
+ {
+ $includePath = $outputDir . '/' . $includeFile;
+ if (!is_dir(\dirname($includePath))) {
+ mkdir(\dirname($includePath), 0777, true);
+ }
+ file_put_contents($includePath, json_encode(['packages' => $packagesByName], JSON_THROW_ON_ERROR));
+ file_put_contents($outputDir . '/packages.json', json_encode([
+ 'includes' => [
+ $includeFile => ['sha256' => 'dummy'],
+ ],
+ ], JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * @return array>>
+ */
+ private function packageEntry(string $name, string $version, string $distUrl, bool $withDist = true): array
+ {
+ $normalized = $version . '.0';
+ if (1 === preg_match('/^\d+\.\d+\.\d+/', $version)) {
+ $normalized = $version;
+ }
+
+ $data = [
+ 'name' => $name,
+ 'version' => $version,
+ 'version_normalized' => $normalized,
+ 'type' => 'library',
+ ];
+ if ($withDist) {
+ $data['dist'] = [
+ 'type' => 'tar',
+ 'url' => $distUrl,
+ ];
+ }
+
+ return [
+ $name => [
+ $version => $data,
+ ],
+ ];
+ }
+
+ /**
+ * @param array $parameters
+ */
+ private function invokeMethod(object $object, string $methodName, array $parameters = []): mixed
+ {
+ $reflection = new \ReflectionClass($object::class);
+ $method = $reflection->getMethod($methodName);
+
+ return $method->invokeArgs($object, $parameters);
+ }
+
+ // --- Data providers ---
+
+ /**
+ * Missing `archive` and `archive` without `directory` hit the same validation branch in PurgeCommand.
+ *
+ * @return iterable}>
+ */
+ public static function provideInvalidArchiveConfig(): iterable
+ {
+ yield 'archive key missing' => [[
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ ]];
+
+ yield 'archive.directory missing' => [[
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'archive' => [],
+ ]];
+ }
+
+ /**
+ * Data providers run before setUp(), so vfs paths must be built inside the test.
+ *
+ * @return iterable
+ */
+ public static function provideOutputDirResolution(): iterable
+ {
+ yield 'from CLI argument' => [true];
+ yield 'from config when argument omitted' => [false];
+ }
+
+ // --- Validation ---
+
+ #[TestDox('execute() returns error when config file is not found')]
+ public function testExecuteReturnsErrorWhenFileNotFound(): void
+ {
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => vfsStream::url('satis/nonexistent.json'),
+ 'output-dir' => vfsStream::url('satis/output'),
+ ]);
+
+ self::assertSame(1, $tester->getStatusCode());
+ self::assertStringContainsString('File not found', $tester->getDisplay());
+ }
+
+ /**
+ * @param array $config
+ */
+ #[DataProvider('provideInvalidArchiveConfig')]
+ #[TestDox('execute() returns error when archive config is invalid')]
+ public function testExecuteReturnsErrorWhenArchiveConfigInvalid(array $config): void
+ {
+ $this->writeSatisConfig($config);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => vfsStream::url('satis/output'),
+ ]);
+
+ self::assertSame(1, $tester->getStatusCode());
+ self::assertStringContainsString('You must define "archive" parameter', $tester->getDisplay());
+ }
+
+ #[TestDox('execute() throws when output dir is not in config or arguments')]
+ public function testExecuteThrowsWhenOutputDirMissing(): void
+ {
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'archive' => [
+ 'directory' => 'dist',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('The output dir must be specified as second argument or be configured inside');
+ $tester->execute([
+ 'file' => $this->configPath,
+ ]);
+ }
+
+ // --- Output directory resolution ---
+
+ #[DataProvider('provideOutputDirResolution')]
+ #[TestDox('execute() resolves output-dir from CLI or config')]
+ public function testExecuteResolvesOutputDir(bool $outputDirFromCliArgument): void
+ {
+ $suffix = $outputDirFromCliArgument ? 'arg' : 'cfg';
+ $outputDir = vfsStream::url('satis/output-' . $suffix);
+ mkdir($outputDir);
+ mkdir($outputDir . '/dist');
+
+ $baseConfig = [
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://example.com',
+ ],
+ ];
+
+ if ($outputDirFromCliArgument) {
+ $this->writeSatisConfig($baseConfig);
+ $executeArgs = [
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ];
+ } else {
+ $this->writeSatisConfig(array_merge($baseConfig, ['output-dir' => $outputDir]));
+ $executeArgs = [
+ 'file' => $this->configPath,
+ ];
+ }
+
+ $tester = $this->createTester();
+ $tester->execute($executeArgs);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('No archives found', $tester->getDisplay());
+ }
+
+ // --- Prefix / env / homepage ---
+
+ #[TestDox('execute() warns when no unreferenced archives are found')]
+ public function testExecuteWarnsWhenNoUnreferencedArchivesFound(): void
+ {
+ $outputDir = vfsStream::url('satis/output-all-ref');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir, 0755, true);
+ mkdir($archiveDir . '/vendor', 0755, true);
+
+ $relativePath = 'vendor/kept.tar.gz';
+ file_put_contents($archiveDir . '/' . $relativePath, 'content');
+
+ $distUrl = 'https://example.com/dist/' . $relativePath;
+ $this->writeOutputRepository($outputDir, $this->packageEntry('vendor/kept', '1.0.0', $distUrl));
+
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://example.com',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('No unreferenced archives found', $tester->getDisplay());
+ self::assertFileExists($archiveDir . '/' . $relativePath);
+ }
+
+ /**
+ * Wrong prefix-url would mark the on-disk file as unreferenced and change the outcome (purge vs this message).
+ */
+ #[TestDox('execute() uses archive prefix-url when set')]
+ public function testExecuteUsesArchivePrefixUrl(): void
+ {
+ $outputDir = vfsStream::url('satis/output-prefix');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir . '/p', 0755, true);
+
+ $relativePath = 'p/archive.tar.gz';
+ file_put_contents($archiveDir . '/' . $relativePath, 'x');
+
+ $this->writeOutputRepository($outputDir, $this->packageEntry(
+ 'p/pkg',
+ '1.0.0',
+ 'https://cdn.example.com/dist/' . $relativePath
+ ));
+
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://cdn.example.com',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('No unreferenced archives found', $tester->getDisplay());
+ }
+
+ #[TestDox('execute() uses SATIS_HOMEPAGE when prefix-url is not defined')]
+ public function testExecuteUsesSatisHomepageEnv(): void
+ {
+ $outputDir = vfsStream::url('satis/output-env');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir . '/vendor', 0755, true);
+
+ $relativePath = 'vendor/file.tar.gz';
+ file_put_contents($archiveDir . '/' . $relativePath, 'c');
+
+ $this->writeOutputRepository($outputDir, $this->packageEntry(
+ 'vendor/pkg',
+ '1.0.0',
+ 'https://custom.example.com/dist/' . $relativePath
+ ));
+
+ $original = getenv('SATIS_HOMEPAGE');
+ putenv('SATIS_HOMEPAGE=https://custom.example.com');
+
+ try {
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('No unreferenced archives found', $tester->getDisplay());
+ } finally {
+ if (false === $original) {
+ putenv('SATIS_HOMEPAGE');
+ } else {
+ putenv('SATIS_HOMEPAGE=' . $original);
+ }
+ }
+ }
+
+ #[TestDox('execute() uses homepage from config when SATIS_HOMEPAGE is unset and prefix-url is missing')]
+ public function testExecuteUsesHomepageFromConfig(): void
+ {
+ $outputDir = vfsStream::url('satis/output-home');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir . '/vendor', 0755, true);
+
+ $relativePath = 'vendor/file.tar.gz';
+ file_put_contents($archiveDir . '/' . $relativePath, 'c');
+
+ $this->writeOutputRepository($outputDir, $this->packageEntry(
+ 'vendor/pkg',
+ '1.0.0',
+ 'https://config.example.com/dist/' . $relativePath
+ ));
+
+ $original = getenv('SATIS_HOMEPAGE');
+ putenv('SATIS_HOMEPAGE');
+
+ try {
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://config.example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('No unreferenced archives found', $tester->getDisplay());
+ } finally {
+ if (false === $original) {
+ putenv('SATIS_HOMEPAGE');
+ } else {
+ putenv('SATIS_HOMEPAGE=' . $original);
+ }
+ }
+ }
+
+ // --- Purge behavior ---
+
+ #[TestDox('execute() removes unreferenced archives when dry-run is disabled')]
+ public function testExecuteRemovesUnreferencedArchives(): void
+ {
+ $outputDir = vfsStream::url('satis/output-purge');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir . '/vendor', 0755, true);
+
+ $referencedFile = $archiveDir . '/vendor/referenced.tar.gz';
+ $unreferencedFile = $archiveDir . '/vendor/unreferenced.tar.gz';
+ file_put_contents($referencedFile, 'a');
+ file_put_contents($unreferencedFile, 'b');
+
+ $this->writeOutputRepository($outputDir, $this->packageEntry(
+ 'vendor/ref',
+ '1.0.0',
+ 'https://example.com/dist/vendor/referenced.tar.gz'
+ ));
+
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://example.com',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('Removed archive', $tester->getDisplay());
+ self::assertStringContainsString('unreferenced.tar.gz', $tester->getDisplay());
+ self::assertFileDoesNotExist($unreferencedFile);
+ self::assertFileExists($referencedFile);
+ }
+
+ #[TestDox('execute() does not count packages without dist type as needed')]
+ public function testExecuteTreatsPackagesWithoutDistTypeAsNotNeeded(): void
+ {
+ $outputDir = vfsStream::url('satis/output-nodist');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir . '/vendor', 0755, true);
+
+ $relativePath = 'vendor/file.tar.gz';
+ file_put_contents($archiveDir . '/' . $relativePath, 'c');
+
+ $this->writeOutputRepository($outputDir, [
+ 'vendor/nodist' => [
+ '1.0.0' => [
+ 'name' => 'vendor/nodist',
+ 'version' => '1.0.0',
+ 'version_normalized' => '1.0.0.0',
+ 'type' => 'library',
+ ],
+ ],
+ ]);
+
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://example.com',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('Removed archive', $tester->getDisplay());
+ self::assertFileDoesNotExist($archiveDir . '/' . $relativePath);
+ }
+
+ #[TestDox('execute() removes empty directories after purging archives')]
+ public function testExecuteRemovesEmptyDirectoriesAfterPurge(): void
+ {
+ $outputDir = vfsStream::url('satis/output-rmdir');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ $subdir = $archiveDir . '/vendor/branch';
+ mkdir($subdir, 0755, true);
+
+ file_put_contents($subdir . '/orphan.tar.gz', 'x');
+
+ $this->writeOutputRepository($outputDir, $this->packageEntry(
+ 'vendor/keep',
+ '1.0.0',
+ 'https://example.com/dist/vendor/branch/kept.tar.gz'
+ ));
+
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://example.com',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertStringContainsString('Removed archive', $tester->getDisplay());
+ self::assertStringContainsString('Removed empty directory', $tester->getDisplay());
+ }
+
+ // --- Dry-run ---
+
+ #[TestDox('execute() does not remove archives or empty dirs when dry-run is enabled')]
+ public function testExecuteDoesNotRemoveArchivesInDryRun(): void
+ {
+ $outputDir = vfsStream::url('satis/output-dry');
+ mkdir($outputDir);
+ $archiveDir = $outputDir . '/dist';
+ mkdir($archiveDir . '/vendor', 0755, true);
+
+ $unreferencedFile = $archiveDir . '/vendor/unreferenced.tar.gz';
+ file_put_contents($unreferencedFile, 'content');
+
+ $this->writeOutputRepository($outputDir, $this->packageEntry(
+ 'vendor/other',
+ '1.0.0',
+ 'https://example.com/dist/vendor/other.tar.gz'
+ ));
+
+ $this->writeSatisConfig([
+ 'name' => 'test/satis-repo',
+ 'homepage' => 'https://example.com',
+ 'output-dir' => $outputDir,
+ 'archive' => [
+ 'directory' => 'dist',
+ 'prefix-url' => 'https://example.com',
+ ],
+ ]);
+
+ $tester = $this->createTester();
+ $tester->execute([
+ 'file' => $this->configPath,
+ 'output-dir' => $outputDir,
+ 'dry-run' => '1',
+ ]);
+
+ self::assertSame(0, $tester->getStatusCode());
+ self::assertFileExists($unreferencedFile);
+ self::assertStringContainsString('Dry run enabled', $tester->getDisplay());
+ self::assertStringContainsString('Removed archive', $tester->getDisplay());
+ self::assertStringNotContainsString('Removed empty directory', $tester->getDisplay());
+ }
+
+ // --- removeEmptyDirectories() ---
+
+ #[TestDox('removeEmptyDirectories() returns false for non-existent directory')]
+ public function testRemoveEmptyDirectoriesReturnsFalseForNonExistentDirectory(): void
+ {
+ $command = new PurgeCommand();
+ $result = $this->invokeMethod($command, 'removeEmptyDirectories', [
+ new NullOutput(),
+ vfsStream::url('satis/nonexistent'),
+ ]);
+
+ self::assertFalse($result);
+ }
+
+ #[TestDox('removeEmptyDirectories() does not remove the root directory passed in')]
+ public function testRemoveEmptyDirectoriesLeavesRootDirectoryInPlace(): void
+ {
+ $root = vfsStream::url('satis/empty-root');
+ mkdir($root);
+
+ $command = new PurgeCommand();
+ $result = $this->invokeMethod($command, 'removeEmptyDirectories', [
+ new NullOutput(),
+ $root,
+ ]);
+
+ self::assertTrue($result);
+ self::assertDirectoryExists($root);
+ }
+
+ #[TestDox('removeEmptyDirectories() removes nested empty child directories')]
+ public function testRemoveEmptyDirectoriesRemovesNestedEmptyDirectories(): void
+ {
+ $base = vfsStream::url('satis/nested-empty');
+ mkdir($base);
+ mkdir($base . '/a');
+ mkdir($base . '/a/b');
+
+ $out = new BufferedOutput();
+ $command = new PurgeCommand();
+ $this->invokeMethod($command, 'removeEmptyDirectories', [$out, $base, 2]);
+
+ self::assertDirectoryDoesNotExist($base . '/a/b');
+ self::assertDirectoryDoesNotExist($base . '/a');
+ self::assertStringContainsString('Removed empty directory', $out->fetch());
+ }
+
+ #[TestDox('removeEmptyDirectories() does not remove non-empty directories')]
+ public function testRemoveEmptyDirectoriesDoesNotRemoveNonEmptyDirectory(): void
+ {
+ $base = vfsStream::url('satis/non-empty');
+ mkdir($base);
+ $dir = $base . '/keep';
+ mkdir($dir);
+ file_put_contents($dir . '/file.txt', 'content');
+
+ $command = new PurgeCommand();
+ $result = $this->invokeMethod($command, 'removeEmptyDirectories', [
+ new NullOutput(),
+ $dir,
+ ]);
+
+ self::assertFalse($result);
+ self::assertDirectoryExists($dir);
+ }
+}