Upstream composer/satis state of the touched files before f38603da21

This commit is contained in:
upstream committed 2026-10-14 19:34:09 +02:00
1 parent 0d162a13f2
commit 222a1d882b
2 files changed
+824

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/*
* This file is part of composer/satis.
*
* (c) Composer <https://github.com/composer>
*
* 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 <info>purge</info> 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('<error>File not found: ' . $configFile . '</error>');
return 1;
}
$config = $file->read();
/*
* Check whether archive is defined
*/
if (!isset($config['archive']) || !isset($config['archive']['directory'])) {
$output->writeln('<error>You must define "archive" parameter in your ' . $configFile . '</error>');
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('<notice>Dry run enabled, no actual changes will be done.</notice>');
}
$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('<warning>No archives found.</warning>');
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('<warning>No unreferenced archives found.</warning>');
return 0;
}
foreach ($unreferenced as $currentFile) {
if (!$dryRun) {
unlink($currentFile->getPathname());
}
$output->writeln(sprintf(
'<info>Removed archive</info>: <comment>%s</comment>',
$currentFile->getRelativePathname()
));
}
if (!$dryRun) {
$this->removeEmptyDirectories($output, $distDirectory);
}
$output->writeln('<info>Done.</info>');
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('<info>Removed empty directory</info>: <comment>%s</comment>', $path));
} else {
$empty = false;
}
}
return $empty;
}
}
+645
View File
@@ -0,0 +1,645 @@
<?php
declare(strict_types=1);
/*
* This file is part of composer/satis.
*
* (c) Composer <https://github.com/composer>
*
* 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<string, mixed> $config
*/
private function writeSatisConfig(array $config): void
{
file_put_contents($this->configPath, json_encode($config, JSON_THROW_ON_ERROR));
}
/**
* @param array<string, array<string, array<string, mixed>>> $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<string, array<string, array<string, mixed>>>
*/
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<int, mixed> $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<string, array{array<string, mixed>}>
*/
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<string, array{0: bool}>
*/
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<string, mixed> $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);
}
}