Repository: php/pie (php)

Issue:
When ext that uses `replace` is already enabled, Composer will report conflict
When a given extension is already **enabled** that uses `replace`, further updates with PIE becomes impossible, because the Composer platform being used is not the `\Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository`, it is the standard `\Composer\Repository\PlatformRepository`, but we need to exclude the extension being installed from being an enabled extension.

```
  Problem 1
    - ext-example_pie_extension is present at version 0.1.0 and cannot be modified by Composer
    - Root composer.json requires asgrim/example-pie-extension ^2 -> satisfiable by asgrim/example-pie-extension[2.0.0, 2.0.1, 2.0.2].
    - asgrim/example-pie-extension[2.0.0, 2.0.1, 2.0.2] cannot be installed as that would require removing ext-example_pie_extension[0.1.0]. asgrim/example-pie-extension replaces ext-example_pie_extension and thus cannot coexist with it.
```

I'm working to fix this by making a custom `\Composer\Installer::createPlatformRepo` that returns the `\Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository` instead, and I will modify it to exclude the extension being installed to avoid the conflict.

Pull request description:
Fix conflict trying to install an ext that is already enabled
Fixes #150 

Patch:
diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php
index 4582c83..d5e33ca 100644
--- a/src/ComposerIntegration/ComposerIntegrationHandler.php
+++ b/src/ComposerIntegration/ComposerIntegrationHandler.php
@@ -53,7 +53,12 @@ class ComposerIntegrationHandler
             $composer->getRepositoryManager()->getLocalRepository()->removePackage($pkg);
         }
 
-        $composerInstaller = Installer::create($this->arrayCollectionIo, $composer);
+        $composerInstaller = PieComposerInstaller::createWithPhpBinary(
+            $targetPlatform->phpBinaryPath,
+            $package->extensionName,
+            $this->arrayCollectionIo,
+            $composer,
+        );
         $composerInstaller
             ->setAllowedTypes(['php-ext', 'php-ext-zend'])
             ->setInstall(true)
diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php
index 72e75de..fae9d17 100644
--- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php
+++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php
@@ -8,6 +8,7 @@ use Composer\Package\CompletePackage;
 use Composer\Pcre\Preg;
 use Composer\Repository\PlatformRepository;
 use Composer\Semver\VersionParser;
+use Php\Pie\ExtensionName;
 use Php\Pie\Platform\TargetPhp\PhpBinaryPath;
 use UnexpectedValueException;
 
@@ -19,7 +20,7 @@ class PhpBinaryPathBasedPlatformRepository extends PlatformRepository
 {
     private VersionParser $versionParser;
 
-    public function __construct(PhpBinaryPath $phpBinaryPath)
+    public function __construct(PhpBinaryPath $phpBinaryPath, ExtensionName|null $extensionBeingInstalled)
     {
         $this->versionParser = new VersionParser();
         $this->packages      = [];
@@ -32,6 +33,16 @@ class PhpBinaryPathBasedPlatformRepository extends PlatformRepository
         $extVersions = $phpBinaryPath->extensions();
 
         foreach ($extVersions as $extension => $extensionVersion) {
+            /**
+             * If the extension we're trying to exclude is not excluded from this list if it is already installed
+             * and enabled, it conflicts when running {@see ComposerIntegrationHandler}.
+             *
+             * @link https://github.com/php/pie/issues/150
+             */
+            if ($extensionBeingInstalled !== null && $extension === $extensionBeingInstalled->name()) {
+                continue;
+            }
+
             $this->addPackage($this->packageForExtension($extension, $extensionVersion));
         }
 
diff --git a/src/ComposerIntegration/PieComposerInstaller.php b/src/ComposerIntegration/PieComposerInstaller.php
new file mode 100644
index 0000000..57febc0
--- /dev/null
+++ b/src/ComposerIntegration/PieComposerInstaller.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\ComposerIntegration;
+
+use Composer\Composer;
+use Composer\Installer;
+use Composer\IO\IOInterface;
+use Composer\Repository\PlatformRepository;
+use Php\Pie\ExtensionName;
+use Php\Pie\Platform\TargetPhp\PhpBinaryPath;
+use Webmozart\Assert\Assert;
+
+/**
+ * @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks
+ *
+ * @psalm-suppress PropertyNotSetInConstructor Property $fixedRootPackage is defined in parent
+ */
+class PieComposerInstaller extends Installer
+{
+    private PhpBinaryPath|null $phpBinaryPath           = null;
+    private ExtensionName|null $extensionBeingInstalled = null;
+
+    protected function createPlatformRepo(bool $forUpdate): PlatformRepository
+    {
+        Assert::notNull($this->phpBinaryPath, '$phpBinaryPath was not set, maybe createWithPhpBinary was not used?');
+        Assert::notNull($this->extensionBeingInstalled, '$extensionBeingInstalled was not set, maybe createWithPhpBinary was not used?');
+
+        return new PhpBinaryPathBasedPlatformRepository($this->phpBinaryPath, $this->extensionBeingInstalled);
+    }
+
+    public static function createWithPhpBinary(
+        PhpBinaryPath $php,
+        ExtensionName $extensionBeingInstalled,
+        IOInterface $io,
+        Composer $composer,
+    ): self {
+        /** @psalm-suppress InvalidArgument some kind of unrelated type mismatch, defined in parent */
+        $composerInstaller = new self(
+            $io,
+            $composer->getConfig(),
+            $composer->getPackage(),
+            $composer->getDownloadManager(),
+            $composer->getRepositoryManager(),
+            $composer->getLocker(),
+            $composer->getInstallationManager(),
+            $composer->getEventDispatcher(),
+            $composer->getAutoloadGenerator(),
+        );
+
+        $composerInstaller->phpBinaryPath           = $php;
+        $composerInstaller->extensionBeingInstalled = $extensionBeingInstalled;
+
+        return $composerInstaller;
+    }
+}
diff --git a/src/ComposerIntegration/VersionSelectorFactory.php b/src/ComposerIntegration/VersionSelectorFactory.php
index 60a6954..ef81ef6 100644
--- a/src/ComposerIntegration/VersionSelectorFactory.php
+++ b/src/ComposerIntegration/VersionSelectorFactory.php
@@ -35,7 +35,7 @@ final class VersionSelectorFactory
     ): VersionSelector {
         return new VersionSelector(
             self::factoryRepositorySet($composer, $requestedPackageAndVersion->version),
-            new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath),
+            new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, null),
         );
     }
 }


Test patch:
diff --git a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php
index 72213d8..1fc7c65 100644
--- a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php
+++ b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php
@@ -6,6 +6,7 @@ namespace Php\PieUnitTest\ComposerIntegration;
 
 use Composer\Package\PackageInterface;
 use Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository;
+use Php\Pie\ExtensionName;
 use Php\Pie\Platform\TargetPhp\PhpBinaryPath;
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\TestCase;
@@ -30,7 +31,7 @@ final class PhpBinaryPathBasedPlatformRepositoryTest extends TestCase
                 'another' => '1.2.3-alpha.34',
             ]);
 
-        $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath);
+        $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, null);
 
         self::assertSame(
             [
@@ -46,4 +47,33 @@ final class PhpBinaryPathBasedPlatformRepositoryTest extends TestCase
             ),
         );
     }
+
+    public function testPlatformRepositoryExcludesExtensionBeingInstalled(): void
+    {
+        $extensionBeingInstalled = ExtensionName::normaliseFromString('extension_being_installed');
+
+        $phpBinaryPath = $this->createMock(PhpBinaryPath::class);
+        $phpBinaryPath->expects(self::once())
+            ->method('version')
+            ->willReturn('8.1.0');
+        $phpBinaryPath->expects(self::once())
+            ->method('extensions')
+            ->willReturn([
+                'foo' => '8.1.0',
+                'extension_being_installed' => '1.2.3',
+            ]);
+
+        $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, $extensionBeingInstalled);
+
+        self::assertSame(
+            [
+                'php:8.1.0',
+                'ext-foo:8.1.0',
+            ],
+            array_map(
+                static fn (PackageInterface $package): string => $package->getName() . ':' . $package->getPrettyVersion(),
+                $platformRepository->getPackages(),
+            ),
+        );
+    }
 }