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')]