Repository: php/pie (php)

Issue:
Cleanup on aisle `vendor`
After discussing with @derickr a bit, everything in the PIE working directory `vendor` except `vendor/composer/installed.json` is mostly just taking up space. It isn't used as a cache for subsequent (re)installs, so it is just taking up space.

Apparently Datadog ext is very large, so this could become problematic if we don't clean up. I imagine some kind of post install cleanup step can run that removes `vendor/*` (except `vendor/composer`), could work, although I need to investigate viability and impact.

Pull request:
Cleanup vendor after install
Fixes #157 

Patch:
diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php
index 05d9c8b..d246a49 100644
--- a/src/ComposerIntegration/ComposerIntegrationHandler.php
+++ b/src/ComposerIntegration/ComposerIntegrationHandler.php
@@ -25,6 +25,7 @@ class ComposerIntegrationHandler
     public function __construct(
         private readonly ContainerInterface $container,
         private readonly QuieterConsoleIO $arrayCollectionIo,
+        private readonly VendorCleanup $vendorCleanup,
     ) {
     }
 
@@ -86,5 +87,7 @@ class ComposerIntegrationHandler
 
             throw ComposerRunFailed::fromExitCode($resultCode);
         }
+
+        ($this->vendorCleanup)($composer);
     }
 }
diff --git a/src/ComposerIntegration/PieComposerFactory.php b/src/ComposerIntegration/PieComposerFactory.php
index 97063ab..d135e61 100644
--- a/src/ComposerIntegration/PieComposerFactory.php
+++ b/src/ComposerIntegration/PieComposerFactory.php
@@ -75,6 +75,7 @@ class PieComposerFactory extends Factory
         );
 
         OverrideWindowsUrlInstallListener::selfRegister($composer, $io, $container, $composerRequest);
+        RemoveUnrelatedInstallOperations::selfRegister($composer, $composerRequest);
 
         $composer->getConfig()->merge(['config' => ['__PIE_REQUEST__' => $composerRequest]]);
         $io->loadConfiguration($composer->getConfig());
diff --git a/src/ComposerIntegration/PiePackageInstaller.php b/src/ComposerIntegration/PiePackageInstaller.php
index 3604907..d2185cc 100644
--- a/src/ComposerIntegration/PiePackageInstaller.php
+++ b/src/ComposerIntegration/PiePackageInstaller.php
@@ -12,6 +12,7 @@ use Composer\PartialComposer;
 use Composer\Repository\InstalledRepositoryInterface;
 use Composer\Util\Filesystem;
 use Php\Pie\ExtensionType;
+use Symfony\Component\Console\Output\OutputInterface;
 
 use function sprintf;
 
@@ -39,11 +40,14 @@ class PiePackageInstaller extends LibraryInstaller
                 $output = $this->composerRequest->pieOutput;
 
                 if ($this->composerRequest->requestedPackage->package !== $composerPackage->getName()) {
-                    $output->writeln(sprintf(
-                        '<error>Not using PIE to install %s as it was not the expected package %s</error>',
-                        $composerPackage->getName(),
-                        $this->composerRequest->requestedPackage->package,
-                    ));
+                    $output->writeln(
+                        sprintf(
+                            '<comment>Skipping %s install request from Composer as it was not the expected PIE package %s</comment>',
+                            $composerPackage->getName(),
+                            $this->composerRequest->requestedPackage->package,
+                        ),
+                        OutputInterface::VERBOSITY_VERY_VERBOSE,
+                    );
 
                     return null;
                 }
diff --git a/src/ComposerIntegration/RemoveUnrelatedInstallOperations.php b/src/ComposerIntegration/RemoveUnrelatedInstallOperations.php
new file mode 100644
index 0000000..874ce7e
--- /dev/null
+++ b/src/ComposerIntegration/RemoveUnrelatedInstallOperations.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\ComposerIntegration;
+
+use Closure;
+use Composer\Composer;
+use Composer\DependencyResolver\Operation\InstallOperation;
+use Composer\DependencyResolver\Operation\OperationInterface;
+use Composer\DependencyResolver\Transaction;
+use Composer\Installer\InstallerEvent;
+use Composer\Installer\InstallerEvents;
+use Symfony\Component\Console\Output\OutputInterface;
+
+use function array_filter;
+use function assert;
+use function sprintf;
+
+/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
+class RemoveUnrelatedInstallOperations
+{
+    public function __construct(
+        private readonly PieComposerRequest $composerRequest,
+    ) {
+    }
+
+    public static function selfRegister(
+        Composer $composer,
+        PieComposerRequest $composerRequest,
+    ): void {
+        $composer
+            ->getEventDispatcher()
+            ->addListener(
+                InstallerEvents::PRE_OPERATIONS_EXEC,
+                new self($composerRequest),
+            );
+    }
+
+    /**
+     * @psalm-suppress InternalProperty
+     * @psalm-suppress InternalMethod
+     */
+    public function __invoke(InstallerEvent $installerEvent): void
+    {
+        $pieOutput = $this->composerRequest->pieOutput;
+
+        $newOperations = array_filter(
+            $installerEvent->getTransaction()?->getOperations() ?? [],
+            function (OperationInterface $operation) use ($pieOutput): bool {
+                if (! $operation instanceof InstallOperation) {
+                    $pieOutput->writeln(
+                        sprintf(
+                            'Unexpected operation during installer: %s',
+                            $operation::class,
+                        ),
+                        OutputInterface::VERBOSITY_VERY_VERBOSE,
+                    );
+
+                    return false;
+                }
+
+                $isRequestedPiePackage = $this->composerRequest->requestedPackage->package === $operation->getPackage()->getName();
+
+                if (! $isRequestedPiePackage) {
+                    $pieOutput->writeln(
+                        sprintf(
+                            'Filtering package %s from install operations, as it was not the requested package',
+                            $operation->getPackage()->getName(),
+                        ),
+                        OutputInterface::VERBOSITY_VERY_VERBOSE,
+                    );
+                }
+
+                return $isRequestedPiePackage;
+            },
+        );
+
+        $overrideOperations = Closure::Bind(
+            static function (Transaction $transaction) use ($newOperations): void {
+                /** @psalm-suppress InaccessibleProperty */
+                $transaction->operations = $newOperations;
+            },
+            null,
+            Transaction::class,
+        );
+        assert($overrideOperations !== null);
+        $overrideOperations($installerEvent->getTransaction());
+    }
+}
diff --git a/src/ComposerIntegration/VendorCleanup.php b/src/ComposerIntegration/VendorCleanup.php
new file mode 100644
index 0000000..035c2d8
--- /dev/null
+++ b/src/ComposerIntegration/VendorCleanup.php
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\ComposerIntegration;
+
+use Composer\Composer;
+use Composer\Util\Filesystem;
+use Symfony\Component\Console\Output\OutputInterface;
+
+use function array_filter;
+use function array_walk;
+use function in_array;
+use function is_array;
+use function scandir;
+use function sprintf;
+
+use const DIRECTORY_SEPARATOR;
+
+/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
+class VendorCleanup
+{
+    public function __construct(
+        private readonly OutputInterface $output,
+        private readonly Filesystem $filesystem,
+    ) {
+    }
+
+    public function __invoke(Composer $composer): void
+    {
+        $vendorDir      = (string) $composer->getConfig()->get('vendor-dir');
+        $vendorContents = scandir($vendorDir);
+
+        if (! is_array($vendorContents)) {
+            $this->output->writeln(
+                sprintf(
+                    '<comment>Vendor directory (vendor-dir config) %s seemed invalid?/comment>',
+                    $vendorDir,
+                ),
+                OutputInterface::VERBOSITY_VERY_VERBOSE,
+            );
+
+            return;
+        }
+
+        $toRemove = array_filter(
+            $vendorContents,
+            static function (string $path): bool {
+                return ! in_array(
+                    $path,
+                    [
+                        '.',
+                        '..',
+                        'autoload.php',
+                        'composer',
+                    ],
+                );
+            },
+        );
+
+        array_walk(
+            $toRemove,
+            function (string $pathToRemove) use ($vendorDir): void {
+                $fullPathToRemove = $vendorDir . DIRECTORY_SEPARATOR . $pathToRemove;
+
+                $this->output->writeln(
+                    sprintf(
+                        '<comment>Removing: %s</comment>',
+                        $fullPathToRemove,
+                    ),
+                    OutputInterface::VERBOSITY_VERY_VERBOSE,
+                );
+
+                if ($this->filesystem->remove($fullPathToRemove)) {
+                    return;
+                }
+
+                $this->output->writeln(
+                    sprintf(
+                        '<comment>Warning: failed to remove %s</comment>',
+                        $fullPathToRemove,
+                    ),
+                    OutputInterface::VERBOSITY_VERBOSE,
+                );
+            },
+        );
+    }
+}


Test patch:
diff --git a/test/assets/vendor-cleanup-dir/autoload.php b/test/assets/vendor-cleanup-dir/autoload.php
new file mode 100644
index 0000000..e69de29
diff --git a/test/assets/vendor-cleanup-dir/composer/dummy b/test/assets/vendor-cleanup-dir/composer/dummy
new file mode 100644
index 0000000..e69de29
diff --git a/test/assets/vendor-cleanup-dir/vendor1/dummy b/test/assets/vendor-cleanup-dir/vendor1/dummy
new file mode 100644
index 0000000..e69de29
diff --git a/test/assets/vendor-cleanup-dir/vendor2/dummy b/test/assets/vendor-cleanup-dir/vendor2/dummy
new file mode 100644
index 0000000..e69de29
diff --git a/test/unit/ComposerIntegration/RemoveUnrelatedInstallOperationsTest.php b/test/unit/ComposerIntegration/RemoveUnrelatedInstallOperationsTest.php
new file mode 100644
index 0000000..0703e3b
--- /dev/null
+++ b/test/unit/ComposerIntegration/RemoveUnrelatedInstallOperationsTest.php
@@ -0,0 +1,136 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\PieUnitTest\ComposerIntegration;
+
+use Composer\Composer;
+use Composer\DependencyResolver\Operation\InstallOperation;
+use Composer\DependencyResolver\Operation\OperationInterface;
+use Composer\DependencyResolver\Transaction;
+use Composer\EventDispatcher\EventDispatcher;
+use Composer\Installer\InstallerEvent;
+use Composer\Installer\InstallerEvents;
+use Composer\IO\IOInterface;
+use Composer\Package\CompletePackage;
+use Php\Pie\ComposerIntegration\PieComposerRequest;
+use Php\Pie\ComposerIntegration\PieOperation;
+use Php\Pie\ComposerIntegration\RemoveUnrelatedInstallOperations;
+use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
+use Php\Pie\Platform\Architecture;
+use Php\Pie\Platform\OperatingSystem;
+use Php\Pie\Platform\OperatingSystemFamily;
+use Php\Pie\Platform\TargetPhp\PhpBinaryPath;
+use Php\Pie\Platform\TargetPlatform;
+use Php\Pie\Platform\ThreadSafetyMode;
+use Php\Pie\Platform\WindowsCompiler;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Output\OutputInterface;
+
+use function array_filter;
+use function array_map;
+
+#[CoversClass(RemoveUnrelatedInstallOperations::class)]
+final class RemoveUnrelatedInstallOperationsTest extends TestCase
+{
+    private Composer&MockObject $composer;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->composer = $this->createMock(Composer::class);
+    }
+
+    public function testEventListenerRegistration(): void
+    {
+        $eventDispatcher = $this->createMock(EventDispatcher::class);
+        $eventDispatcher
+            ->expects(self::once())
+            ->method('addListener')
+            ->with(
+                InstallerEvents::PRE_OPERATIONS_EXEC,
+                self::isInstanceOf(RemoveUnrelatedInstallOperations::class),
+            );
+
+        $this->composer
+            ->expects(self::once())
+            ->method('getEventDispatcher')
+            ->willReturn($eventDispatcher);
+
+        RemoveUnrelatedInstallOperations::selfRegister(
+            $this->composer,
+            new PieComposerRequest(
+                $this->createMock(OutputInterface::class),
+                new TargetPlatform(
+                    OperatingSystem::NonWindows,
+                    OperatingSystemFamily::Linux,
+                    PhpBinaryPath::fromCurrentProcess(),
+                    Architecture::x86_64,
+                    ThreadSafetyMode::NonThreadSafe,
+                    1,
+                    WindowsCompiler::VC15,
+                ),
+                new RequestedPackageAndVersion('foo/bar', '^1.1'),
+                PieOperation::Install,
+                [],
+                null,
+                false,
+            ),
+        );
+    }
+
+    /** @psalm-suppress InternalMethod */
+    public function testUnrelatedInstallOperationsAreRemoved(): void
+    {
+        $composerPackage1 = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3');
+        $composerPackage2 = new CompletePackage('bat/baz', '3.4.5.0', '3.4.5');
+        $composerPackage3 = new CompletePackage('qux/quux', '5.6.7.0', '5.6.7');
+
+        /**
+         * @psalm-suppress InternalClass
+         * @psalm-suppress InternalMethod
+         */
+        $installerEvent = new InstallerEvent(
+            InstallerEvents::PRE_OPERATIONS_EXEC,
+            $this->composer,
+            $this->createMock(IOInterface::class),
+            false,
+            true,
+            new Transaction([], [$composerPackage1, $composerPackage2, $composerPackage3]),
+        );
+
+        (new RemoveUnrelatedInstallOperations(
+            new PieComposerRequest(
+                $this->createMock(OutputInterface::class),
+                new TargetPlatform(
+                    OperatingSystem::Windows,
+                    OperatingSystemFamily::Linux,
+                    PhpBinaryPath::fromCurrentProcess(),
+                    Architecture::x86_64,
+                    ThreadSafetyMode::NonThreadSafe,
+                    1,
+                    WindowsCompiler::VC15,
+                ),
+                new RequestedPackageAndVersion('bat/baz', '^3.2'),
+                PieOperation::Install,
+                [],
+                null,
+                false,
+            ),
+        ))($installerEvent);
+
+        self::assertSame(
+            ['bat/baz'],
+            array_map(
+                static fn (InstallOperation $operation): string => $operation->getPackage()->getName(),
+                array_filter(
+                    $installerEvent->getTransaction()?->getOperations() ?? [],
+                    static fn (OperationInterface $operation): bool => $operation instanceof InstallOperation,
+                ),
+            ),
+        );
+    }
+}
diff --git a/test/unit/ComposerIntegration/VendorCleanupTest.php b/test/unit/ComposerIntegration/VendorCleanupTest.php
new file mode 100644
index 0000000..0d45528
--- /dev/null
+++ b/test/unit/ComposerIntegration/VendorCleanupTest.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\PieUnitTest\ComposerIntegration;
+
+use Composer\Composer;
+use Composer\Config;
+use Composer\Util\Filesystem;
+use Php\Pie\ComposerIntegration\VendorCleanup;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Output\OutputInterface;
+
+use function restore_error_handler;
+use function set_error_handler;
+
+use const DIRECTORY_SEPARATOR;
+use const E_WARNING;
+
+#[CoversClass(VendorCleanup::class)]
+final class VendorCleanupTest extends TestCase
+{
+    private const VENDOR_DIR = __DIR__ . '/../../assets/vendor-cleanup-dir';
+
+    private OutputInterface&MockObject $output;
+    private Filesystem&MockObject $filesystem;
+    private VendorCleanup $vendorCleanup;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->output     = $this->createMock(OutputInterface::class);
+        $this->filesystem = $this->createMock(Filesystem::class);
+
+        $this->vendorCleanup = new VendorCleanup($this->output, $this->filesystem);
+    }
+
+    private function composerWithVendorDirConfig(string $vendorDirConfig): Composer&MockObject
+    {
+        $config = $this->createMock(Config::class);
+        $config->method('get')
+            ->with('vendor-dir')
+            ->willReturn($vendorDirConfig);
+
+        $composer = $this->createMock(Composer::class);
+        $composer->method('getConfig')
+            ->willReturn($config);
+
+        return $composer;
+    }
+
+    public function testInvalidVendorDirectory(): void
+    {
+        $this->output
+            ->expects(self::once())
+            ->method('writeln')
+            ->with(
+                '<comment>Vendor directory (vendor-dir config) /path/that/does/not/exist seemed invalid?/comment>',
+                OutputInterface::VERBOSITY_VERY_VERBOSE,
+            );
+
+        /**
+         * scandir will emit a warning in this case, causing phpunit to fail with warning
+         *
+         * @psalm-suppress InvalidArgument
+         */
+        set_error_handler(
+            static function (): bool|null {
+                return null;
+            },
+            E_WARNING,
+        );
+        ($this->vendorCleanup)($this->composerWithVendorDirConfig('/path/that/does/not/exist'));
+        restore_error_handler();
+    }
+
+    public function testVendorDirIsCleaned(): void
+    {
+        $vendor1Removed = false;
+        $vendor2Removed = false;
+
+        $this->filesystem
+            ->expects(self::exactly(2))
+            ->method('remove')
+            ->willReturnCallback(static function (string $path) use (&$vendor1Removed, &$vendor2Removed): bool {
+                return match ($path) {
+                    self::VENDOR_DIR . DIRECTORY_SEPARATOR . 'vendor1' => $vendor1Removed = true,
+                    self::VENDOR_DIR . DIRECTORY_SEPARATOR . 'vendor2' => $vendor2Removed = true,
+                };
+            });
+
+        ($this->vendorCleanup)($this->composerWithVendorDirConfig(self::VENDOR_DIR));
+
+        self::assertTrue($vendor1Removed);
+        self::assertTrue($vendor2Removed);
+    }
+}