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); + } +}