diff --git a/src/Installer.php b/src/Installer.php index dea0718..8ea3794 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -28,6 +28,8 @@ class Installer extends LibraryInstaller const DEFAULT_UPDATE_STRATEGY = 'merge'; + protected bool $skipUpdateCode = false; + const UPDATE_STRATEGY_MERGE = 'merge'; const UPDATE_STRATEGY_OVERWRITE = 'overwrite'; @@ -273,13 +275,30 @@ protected function mergeStash(string $stash, string $base, string $install): voi } } + /** + * Returns true when the package is served by a local path repository. + * Path repos have source == install path, so the normal download/delete cycle would + * wipe the user's files before trying to copy from the now-missing source. + */ + protected function isPathRepository(PackageInterface $package): bool + { + return $package->getDistType() === 'path'; + } + /** * Override install to remove excluded directories after installation. + * Skips entirely for path repositories — their files are already in place. * * {@inheritDoc} */ public function install(InstalledRepositoryInterface $repo, PackageInterface $package): ?PromiseInterface { + if ($this->isPathRepository($package)) { + $this->io->write(" - Skipping install for path repository: {$package->getPrettyName()}"); + + return \React\Promise\resolve(null); + } + $promise = parent::install($repo, $package); return $promise->then(function () use ($package) { @@ -287,13 +306,78 @@ public function install(InstalledRepositoryInterface $repo, PackageInterface $pa }); } + /** + * Call parent::update() solely for its installed.json tracking side-effects. + * Sets the skip flag so updateCode() is a no-op, then resets it regardless of outcome. + */ + protected function delegateRepoTracking(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target): PromiseInterface + { + $this->skipUpdateCode = true; + + $resetFlag = function (): void { + $this->skipUpdateCode = false; + }; + + return parent::update($repo, $initial, $target)->then( + $resetFlag, + function (\Throwable $e) use ($resetFlag): never { + $resetFlag(); + throw $e; + } + ); + } + + /** + * No-op hook for parent::update() — skipped when we have already handled the download ourselves. + * Overriding this lets parent::update() run only for its repo-tracking side-effects. + * + * {@inheritDoc} + */ + protected function updateCode(PackageInterface $initial, PackageInterface $target): PromiseInterface + { + if ($this->skipUpdateCode) { + return \React\Promise\resolve(null); + } + + return $this->invokeParentUpdateCode($initial, $target); + } + + protected function invokeParentUpdateCode(PackageInterface $initial, PackageInterface $target): PromiseInterface + { + return parent::updateCode($initial, $target); + } + + /** + * Download a specific package version to the given path using Composer's DownloadManager. + * Used for both base (original) and target (new) version fetches during update. + */ + protected function downloadFresh(PackageInterface $package, string $path): PromiseInterface + { + $dm = $this->composer->getDownloadManager(); + + return $dm->download($package, $path) + ->then(fn () => $dm->install($package, $path)); + } + /** * Override update to preserve user customisations (merge strategy) or replace entirely (overwrite). + * Skips entirely for path repositories — their files are managed by git/the path repo mechanism. + * + * Does NOT call parent::update() because that uses GitDownloader::update() which requires a .git + * directory. Since we remove .git after initial install (copy-and-own model), we instead do a + * fresh download of the target version directly to the install path. * * {@inheritDoc} */ public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target): ?PromiseInterface { + if ($this->isPathRepository($target)) { + $this->io->write(" - Skipping update for path repository: {$target->getPrettyName()}"); + + return \React\Promise\resolve(null); + } + + $installPath = $this->getInstallPath($target); $stashPath = null; $basePath = null; @@ -302,31 +386,65 @@ public function update(InstalledRepositoryInterface $repo, PackageInterface $ini if ($stashPath !== null) { $basePath = sys_get_temp_dir().'/module-base-'.uniqid('', true); } + } else { + // Overwrite: stash for rollback safety rather than deleting outright. + // $basePath stays null, which signals overwrite mode in the callbacks. + $stashPath = $this->stashModuleDir($installPath); } $prepareBase = ($basePath !== null) ? $this->downloadBase($initial, $basePath) : \React\Promise\resolve(null); - return $prepareBase->then(fn () => parent::update($repo, $initial, $target))->then( - function () use ($target, $stashPath, $basePath) { - $this->removeExcludedDirectories($target); - if ($stashPath !== null) { - $installPath = $this->getInstallPath($target); - $this->mergeStash($stashPath, $basePath, $installPath); - (new Filesystem)->removeDirectory($stashPath); - (new Filesystem)->removeDirectory($basePath); - } - }, - function (\Throwable $e) use ($stashPath, $basePath, $initial) { - if ($stashPath !== null) { - $this->restoreStash($stashPath, $initial); - } - if ($basePath !== null) { - (new Filesystem)->removeDirectory($basePath); + return $prepareBase + ->then(fn () => $this->downloadFresh($target, $installPath)) + ->then( + function () use ($repo, $initial, $target, $installPath, $stashPath, $basePath) { + $this->removeExcludedDirectories($target); + if ($basePath !== null) { + // Merge strategy: apply 3-way merge then clean up base temp dir + $this->mergeStash($stashPath, $basePath, $installPath); + (new Filesystem)->removeDirectory($basePath); + } + if ($stashPath !== null) { + // Both strategies: download succeeded — discard the stash + (new Filesystem)->removeDirectory($stashPath); + } + + // Delegate repo tracking (installed.json) to parent — skip the download step + // since we have already placed the files ourselves. + return $this->delegateRepoTracking($repo, $initial, $target); + }, + function (\Throwable $e) use ($stashPath, $basePath, $initial) { + if ($stashPath !== null) { + $this->restoreStash($stashPath, $initial); + } + if ($basePath !== null) { + (new Filesystem)->removeDirectory($basePath); + } + throw $e; } - throw $e; + ); + } + + /** + * Override uninstall to protect path repository files from deletion. + * A `composer remove` on a path repo must never wipe the working source directory. + * + * {@inheritDoc} + */ + public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $package): ?PromiseInterface + { + if ($this->isPathRepository($package)) { + $this->io->write(" - Skipping uninstall for path repository: {$package->getPrettyName()}"); + + if ($repo->hasPackage($package)) { + $repo->removePackage($package); } - ); + + return \React\Promise\resolve(null); + } + + return parent::uninstall($repo, $package); } } diff --git a/tests/ModuleInstallerTest.php b/tests/ModuleInstallerTest.php index c947f84..5b2c87a 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -10,7 +10,9 @@ use Composer\Package\PackageInterface; use Composer\Package\RootPackage; use Composer\PartialComposer; +use Composer\Repository\InstalledRepositoryInterface; use PHPUnit\Framework\TestCase; +use React\Promise\PromiseInterface; use Saucebase\ModuleInstaller\Exceptions\ModuleInstallerException; use Saucebase\ModuleInstaller\Installer; use Symfony\Component\Filesystem\Filesystem; @@ -58,6 +60,30 @@ public function callRestoreStash(string $stashPath, PackageInterface $package): { parent::restoreStash($stashPath, $package); } + + public function callIsPathRepository(PackageInterface $package): bool + { + return parent::isPathRepository($package); + } + + public function callUpdateCode(PackageInterface $initial, PackageInterface $target): PromiseInterface + { + return $this->updateCode($initial, $target); + } + + public function setSkipUpdateCode(bool $value): void + { + $this->skipUpdateCode = $value; + } + + public bool $parentUpdateCodeInvoked = false; + + protected function invokeParentUpdateCode(PackageInterface $initial, PackageInterface $target): PromiseInterface + { + $this->parentUpdateCodeInvoked = true; + + return parent::invokeParentUpdateCode($initial, $target); + } } final class ModuleInstallerTest extends TestCase @@ -470,4 +496,151 @@ public function test_restore_stash_discards_stash_when_original_path_already_exi (new Filesystem)->remove($originalPath); } + + // ------------------------------------------------------------------------- + // isPathRepository + // ------------------------------------------------------------------------- + + public function test_is_path_repository_returns_true_for_path_dist_type(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getDistType')->willReturn('path'); + + $this->assertTrue($installer->callIsPathRepository($pkg)); + } + + public function test_is_path_repository_returns_false_for_zip_dist_type(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getDistType')->willReturn('zip'); + + $this->assertFalse($installer->callIsPathRepository($pkg)); + } + + public function test_is_path_repository_returns_false_when_dist_type_is_null(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getDistType')->willReturn(null); + + $this->assertFalse($installer->callIsPathRepository($pkg)); + } + + // ------------------------------------------------------------------------- + // install() path-repository guard + // ------------------------------------------------------------------------- + + public function test_install_logs_skip_message_and_returns_promise_for_path_repo(): void + { + $io = $this->createMock(IOInterface::class); + $io->expects($this->once()) + ->method('write') + ->with($this->stringContains('Skipping install')); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getDistType')->willReturn('path'); + $pkg->method('getPrettyName')->willReturn('saucebase/test'); + + $repo = $this->createStub(InstalledRepositoryInterface::class); + $installer = new TestableInstaller($io, null); + + $promise = $installer->install($repo, $pkg); + + $this->assertNotNull($promise); + } + + // ------------------------------------------------------------------------- + // update() path-repository guard + // ------------------------------------------------------------------------- + + public function test_update_logs_skip_message_and_returns_promise_for_path_repo(): void + { + $io = $this->createMock(IOInterface::class); + $io->expects($this->once()) + ->method('write') + ->with($this->stringContains('Skipping update')); + + $initial = $this->createStub(PackageInterface::class); + + $target = $this->createStub(PackageInterface::class); + $target->method('getDistType')->willReturn('path'); + $target->method('getPrettyName')->willReturn('saucebase/test'); + + $repo = $this->createStub(InstalledRepositoryInterface::class); + $installer = new TestableInstaller($io, null); + + $promise = $installer->update($repo, $initial, $target); + + $this->assertNotNull($promise); + } + + // ------------------------------------------------------------------------- + // uninstall() path-repository guard + // ------------------------------------------------------------------------- + + public function test_uninstall_logs_skip_message_and_removes_package_from_repo(): void + { + $io = $this->createMock(IOInterface::class); + $io->expects($this->once()) + ->method('write') + ->with($this->stringContains('Skipping uninstall')); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getDistType')->willReturn('path'); + $pkg->method('getPrettyName')->willReturn('saucebase/test'); + + $repo = $this->createMock(InstalledRepositoryInterface::class); + $repo->method('hasPackage')->willReturn(true); + $repo->expects($this->once())->method('removePackage')->with($pkg); + + $installer = new TestableInstaller($io, null); + $installer->uninstall($repo, $pkg); + } + + public function test_uninstall_skips_remove_package_when_package_not_in_repo(): void + { + $io = $this->createMock(IOInterface::class); + $io->expects($this->once())->method('write'); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getDistType')->willReturn('path'); + $pkg->method('getPrettyName')->willReturn('saucebase/test'); + + $repo = $this->createMock(InstalledRepositoryInterface::class); + $repo->method('hasPackage')->willReturn(false); + $repo->expects($this->never())->method('removePackage'); + + $installer = new TestableInstaller($io, null); + $installer->uninstall($repo, $pkg); + } + + // ------------------------------------------------------------------------- + // updateCode() skip flag + // ------------------------------------------------------------------------- + + public function test_update_code_returns_resolved_promise_when_skip_flag_is_set(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); + $installer->setSkipUpdateCode(true); + + $initial = $this->createStub(PackageInterface::class); + $target = $this->createStub(PackageInterface::class); + + $resolved = false; + $installer->callUpdateCode($initial, $target)->then(function () use (&$resolved) { + $resolved = true; + }); + + $this->assertTrue($resolved, 'updateCode should resolve immediately when skip flag is set'); + $this->assertFalse($installer->parentUpdateCodeInvoked, 'parent::updateCode() must not be called when skip flag is set'); + } }