diff --git a/src/Console/Command/PurgeCommand.php b/src/Console/Command/PurgeCommand.php index bbef3d2..cdc3f02 100644 --- a/src/Console/Command/PurgeCommand.php +++ b/src/Console/Command/PurgeCommand.php @@ -16,6 +16,7 @@ namespace Composer\Satis\Console\Command; use Composer\Command\BaseCommand; use Composer\Json\JsonFile; use Composer\Satis\PackageSelection\PackageSelection; +use Composer\Util\Filesystem; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -127,9 +128,24 @@ class PurgeCommand extends BaseCommand return 0; } + $filesystem = new Filesystem(); foreach ($unreferenced as $currentFile) { if (!$dryRun) { - unlink($currentFile->getPathname()); + try { + $filesystem->unlink($currentFile->getPathname()); + } catch (\RuntimeException $exception) { + // Another process, e.g. a concurrent build or purge, may have removed the archive already. + if (file_exists($currentFile->getPathname())) { + throw $exception; + } + + $output->writeln(sprintf( + 'Skipped missing archive: %s', + $currentFile->getRelativePathname() + )); + + continue; + } } $output->writeln(sprintf( @@ -166,7 +182,7 @@ class PurgeCommand extends BaseCommand if (is_dir($path) && $depth > 0 && $this->removeEmptyDirectories($output, $path, $depth - 1) - && rmdir($path) + && @rmdir($path) ) { $output->writeln(sprintf('Removed empty directory: %s', $path)); } else { diff --git a/tests/Console/Command/PurgeCommandTest.php b/tests/Console/Command/PurgeCommandTest.php index 7894b6f..2a57c22 100644 --- a/tests/Console/Command/PurgeCommandTest.php +++ b/tests/Console/Command/PurgeCommandTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Tester\CommandTester; @@ -534,6 +535,121 @@ class PurgeCommandTest extends TestCase self::assertStringContainsString('Removed empty directory', $tester->getDisplay()); } + #[TestDox('execute() skips unreferenced archives that are already gone when they are about to be removed')] + public function testExecuteSkipsArchivesThatDisappearDuringPurge(): void + { + $outputDir = vfsStream::url('satis/output-vanished'); + mkdir($outputDir); + $archiveDir = $outputDir . '/dist'; + mkdir($archiveDir . '/vendor', 0755, true); + + $archives = [ + $archiveDir . '/vendor/first.tar.gz', + $archiveDir . '/vendor/second.tar.gz', + ]; + foreach ($archives as $archive) { + file_put_contents($archive, 'x'); + } + + $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', + ], + ]); + + // Simulates a concurrent build or purge: once the first archive is removed, the others vanish too. + $output = new class($archives) extends BufferedOutput { + /** + * @param list $archives + */ + public function __construct(private readonly array $archives) + { + parent::__construct(); + } + + protected function doWrite(string $message, bool $newline): void + { + parent::doWrite($message, $newline); + + if (str_contains($message, 'Removed archive')) { + foreach ($this->archives as $archive) { + if (file_exists($archive)) { + unlink($archive); + } + } + } + } + }; + + $input = new ArrayInput([ + 'file' => $this->configPath, + 'output-dir' => $outputDir, + ]); + $input->setInteractive(false); + + $app = new Application(); + $app->addCommand(new PurgeCommand()); + + $statusCode = $app->find('purge')->run($input, $output); + + $display = $output->fetch(); + self::assertSame(0, $statusCode); + self::assertSame(1, substr_count($display, 'Removed archive')); + self::assertStringContainsString('Skipped missing archive', $display); + self::assertStringContainsString('Done.', $display); + foreach ($archives as $archive) { + self::assertFileDoesNotExist($archive); + } + } + + #[TestDox('execute() throws when an unreferenced archive exists but cannot be removed')] + public function testExecuteThrowsWhenArchiveCannotBeRemoved(): void + { + $outputDir = vfsStream::url('satis/output-locked'); + mkdir($outputDir); + $archiveDir = $outputDir . '/dist'; + mkdir($archiveDir . '/vendor', 0755, true); + + $unreferencedFile = $archiveDir . '/vendor/unreferenced.tar.gz'; + file_put_contents($unreferencedFile, 'x'); + chmod($archiveDir . '/vendor', 0555); + + $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(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Could not delete ' . $unreferencedFile); + $tester->execute([ + 'file' => $this->configPath, + 'output-dir' => $outputDir, + ]); + } + // --- Dry-run --- #[TestDox('execute() does not remove archives or empty dirs when dry-run is enabled')]