diff --git a/.gitignore b/.gitignore index aa0f446..4305edb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -studio.json +/studio.json +/.studio/ composer.phar composer.lock vendor/ diff --git a/spec/Composer/StudioPluginSpec.php b/spec/Composer/StudioPluginSpec.php new file mode 100644 index 0000000..4d5438f --- /dev/null +++ b/spec/Composer/StudioPluginSpec.php @@ -0,0 +1,314 @@ +shouldHaveType('Studio\Composer\StudioPlugin'); + } + + function it_is_activatable(Composer $composer, IOInterface $io, RepositoryManager $repositoryManager) + { + // Mock methods + $composer->getInstallationManager()->willReturn(null); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn(null); + $composer->getRepositoryManager()->willReturn($repositoryManager); + + $this->activate($composer, $io); + } + + function it_resolves_subscribed_events() + { + self::getSubscribedEvents()->shouldReturn([ + ScriptEvents::PRE_UPDATE_CMD => 'unlinkStudioPackages', + ScriptEvents::POST_UPDATE_CMD => 'symlinkStudioPackages', + ScriptEvents::POST_INSTALL_CMD => 'symlinkStudioPackages', + ScriptEvents::PRE_AUTOLOAD_DUMP => 'symlinkStudioPackages' + ]); + } + + /** + * Test if studio does not create symlinks when no studio.json is defined + */ + function it_doesnt_create_symlinks_without_file($composer, $io, $rootPackage, $filesystem, $repositoryManager) + { + // switch working directory + chdir(__DIR__); + + // create stubs + $filesystem->beADoubleOf('Composer\Util\Filesystem'); + $rootPackage->beADoubleOf('Composer\Package\RootPackage'); + $composer->beADoubleOf('Composer\Composer'); + $io->beADoubleOf('Composer\IO\IOInterface'); + $repositoryManager->beADoubleOf('Composer\Repository\RepositoryManager'); + + // Construct + $this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn(null); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn($rootPackage); + $composer->getRepositoryManager()->willReturn($repositoryManager); + $rootPackage->getTargetDir()->willReturn(getcwd()); + + // Test + $this->activate($composer, $io); + $this->symlinkStudioPackages(); + } + + /** + * Test if studio does not unlink when no studio.json or .studio/studio.json is defined + */ + function it_doesnt_unlink_without_files($composer, $io, $rootPackage, $filesystem, $repositoryManager) + { + // switch working directory + chdir(__DIR__); + + // create stubs + $filesystem->beADoubleOf('Composer\Util\Filesystem'); + $rootPackage->beADoubleOf('Composer\Package\RootPackage'); + $composer->beADoubleOf('Composer\Composer'); + $io->beADoubleOf('Composer\IO\IOInterface'); + $repositoryManager->beADoubleOf('Composer\Repository\RepositoryManager'); + + // Construct + $this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn(null); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn($rootPackage); + $composer->getRepositoryManager()->willReturn($repositoryManager); + $rootPackage->getTargetDir()->willReturn(getcwd()); + + // Test + $this->activate($composer, $io); + $this->unlinkStudioPackages(); + } + + /** + * Test if studio does create symlinks when studio.json is defined + */ + function it_does_create_symlinks_with_file( + $composer, + $io, + $rootPackage, + $filesystem, + $installationManager, + $downloadManager, + $pathDownloader, + $repositoryManager, + $localRepository, + $libraryPackage, + $library2Package + ) { + // switch working directory + chdir(__DIR__ . '/stubs/project-with-path'); + + // create stubs + $filesystem->beADoubleOf('Composer\Util\Filesystem'); + $rootPackage->beADoubleOf('Composer\Package\RootPackage'); + $composer->beADoubleOf('Composer\Composer'); + $io->beADoubleOf('Composer\IO\IOInterface'); + $installationManager->beADoubleOf('Composer\Installer\InstallationManager'); + $downloadManager->beADoubleOf('Composer\Downloader\DownloadManager'); + $pathDownloader->beADoubleOf('Composer\Downloader\PathDownloader'); + $repositoryManager->beADoubleOf('Composer\Repository\RepositoryManager'); + $localRepository->beADoubleOf('Composer\Repository\WritableRepositoryInterface'); + $libraryPackage->beADoubleOf('Composer\Package\Package'); + $library2Package->beADoubleOf('Composer\Package\Package'); + + $libraryPackage->getName()->willReturn("acme/library"); + $library2Package->getName()->willReturn("acme/library2"); + + $localRepository->getPackages()->willReturn([$libraryPackage, $library2Package]); + $repositoryManager->getLocalRepository()->willReturn($localRepository); + + // Construct + //$this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn($installationManager); + $composer->getDownloadManager()->willReturn($downloadManager); + $composer->getPackage()->willReturn($rootPackage); + $composer->getRepositoryManager()->willReturn($repositoryManager); + $rootPackage->getTargetDir()->willReturn(getcwd()); + $downloadManager->getDownloader('path') + ->willReturn($pathDownloader) + ->shouldBeCalled(); + + $io->write('[Studio] Creating link to ../libs/library for package acme/library')->shouldBeCalled(); + $io->write('[Studio] Creating link to ../libs/library2 for package acme/library2')->shouldNotBeCalled(); + $io->write('[Studio] Creating link to ../libs/another-library for package acme/another-library')->shouldNotBeCalled(); + + // Test + $this->activate($composer, $io); + $this->symlinkStudioPackages(); + } + + + /** + * Test if studio does unlink when studio.json is defined + */ + function it_does_unlink_with_file( + $composer, + $io, + $rootPackage, + $filesystem, + $installationManager, + $pathDownloader, + $repositoryManager + ) { + // switch working directory + chdir(__DIR__ . '/stubs/project-with-unload'); + + // create stubs + $filesystem->beADoubleOf('Composer\Util\Filesystem'); + $rootPackage->beADoubleOf('Composer\Package\RootPackage'); + $composer->beADoubleOf('Composer\Composer'); + $io->beADoubleOf('Composer\IO\IOInterface'); + $installationManager->beADoubleOf('Composer\Installer\InstallationManager'); + $pathDownloader->beADoubleOf('Composer\Downloader\PathDownloader'); + $repositoryManager->beADoubleOf('Composer\Repository\RepositoryManager'); + + // Construct + $this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn($installationManager); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn($rootPackage); + $composer->getRepositoryManager()->willReturn($repositoryManager); + $rootPackage->getTargetDir()->willReturn(getcwd()); + $filesystem->isSymlinkedDirectory(null)->willReturn(true)->shouldBeCalled(); + $filesystem->removeDirectory(null)->shouldBeCalled(); + + $io->write('[Studio] Removing linked path ../libs/library for package acme/library')->shouldBeCalled(); + $io->write('[Studio] Removing linked path ../libs/library2 for package acme/library2')->shouldNotBeCalled(); + $io->write('[Studio] Removing linked path ../libs/another-library for package acme/another-library')->shouldNotBeCalled(); + + // Test + $this->activate($composer, $io); + $this->unlinkStudioPackages(); + } + + /** + * Test if studio does create symlinks when studio.json is defined and contains wildcards path + */ + function it_does_create_symlinks_with_file_and_wildcard_paths( + $composer, + $io, + $rootPackage, + $filesystem, + $installationManager, + $downloadManager, + $pathDownloader, + $repositoryManager, + $localRepository, + $libraryPackage, + $library2Package + ) { + // switch working directory + chdir(__DIR__ . '/stubs/project-with-path-wildcard'); + + // create stubs + $filesystem->beADoubleOf('Composer\Util\Filesystem'); + $rootPackage->beADoubleOf('Composer\Package\RootPackage'); + $composer->beADoubleOf('Composer\Composer'); + $io->beADoubleOf('Composer\IO\IOInterface'); + $installationManager->beADoubleOf('Composer\Installer\InstallationManager'); + $downloadManager->beADoubleOf('Composer\Downloader\DownloadManager'); + $pathDownloader->beADoubleOf('Composer\Downloader\PathDownloader'); + $repositoryManager->beADoubleOf('Composer\Repository\RepositoryManager'); + $localRepository->beADoubleOf('Composer\Repository\WritableRepositoryInterface'); + $libraryPackage->beADoubleOf('Composer\Package\Package'); + $library2Package->beADoubleOf('Composer\Package\Package'); + + $libraryPackage->getName()->willReturn("acme/library"); + $library2Package->getName()->willReturn("acme/library2"); + + $localRepository->getPackages()->willReturn([$libraryPackage, $library2Package]); + $repositoryManager->getLocalRepository()->willReturn($localRepository); + + $repositoryManager->getLocalRepository()->willReturn($localRepository); + + // Construct + //$this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn($installationManager); + $composer->getDownloadManager()->willReturn($downloadManager); + $composer->getPackage()->willReturn($rootPackage); + $composer->getRepositoryManager()->willReturn($repositoryManager); + $rootPackage->getTargetDir()->willReturn(getcwd()); + $downloadManager->getDownloader('path') + ->willReturn($pathDownloader) + ->shouldBeCalled(); + + $io->write('[Studio] Creating link to ../libs/library for package acme/library')->shouldBeCalled(); + $io->write('[Studio] Creating link to ../libs/library2 for package acme/library2')->shouldBeCalled(); + $io->write('[Studio] Creating link to ../libs/another-library for package acme/another-library')->shouldNotBeCalled(); + + // Test + $this->activate($composer, $io); + $this->symlinkStudioPackages(); + } + + + /** + * Test if studio does unlink when studio.json is defined and contains wildcard paths. + */ + function it_does_unlink_with_file_and_wildcard_paths( + $composer, + $io, + $rootPackage, + $filesystem, + $installationManager, + $pathDownloader, + $repositoryManager + ) { + // switch working directory + chdir(__DIR__ . '/stubs/project-with-unload-wildcard'); + + // create stubs + $filesystem->beADoubleOf('Composer\Util\Filesystem'); + $rootPackage->beADoubleOf('Composer\Package\RootPackage'); + $composer->beADoubleOf('Composer\Composer'); + $io->beADoubleOf('Composer\IO\IOInterface'); + $installationManager->beADoubleOf('Composer\Installer\InstallationManager'); + $pathDownloader->beADoubleOf('Composer\Downloader\PathDownloader'); + $repositoryManager->beADoubleOf('Composer\Repository\RepositoryManager'); + + // Construct + $this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn($installationManager); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn($rootPackage); + $composer->getRepositoryManager()->willReturn($repositoryManager); + $rootPackage->getTargetDir()->willReturn(getcwd()); + $filesystem->isSymlinkedDirectory(null)->willReturn(true)->shouldBeCalled(); + $filesystem->removeDirectory(null)->shouldBeCalled(); + + $io->write('[Studio] Removing linked path ../libs/library for package acme/library')->shouldBeCalled(); + $io->write('[Studio] Removing linked path ../libs/library2 for package acme/library2')->shouldBeCalled(); + $io->write('[Studio] Removing linked path ../libs/another-library for package acme/another-library')->shouldBeCalled(); + + // Test + $this->activate($composer, $io); + $this->unlinkStudioPackages(); + } +} diff --git a/spec/Composer/stubs/libs/another-library/composer.json b/spec/Composer/stubs/libs/another-library/composer.json new file mode 100644 index 0000000..450a4b7 --- /dev/null +++ b/spec/Composer/stubs/libs/another-library/composer.json @@ -0,0 +1,3 @@ +{ + "name": "acme/another-library" +} \ No newline at end of file diff --git a/spec/Composer/stubs/libs/library/composer.json b/spec/Composer/stubs/libs/library/composer.json new file mode 100644 index 0000000..ae0bdf7 --- /dev/null +++ b/spec/Composer/stubs/libs/library/composer.json @@ -0,0 +1,3 @@ +{ + "name": "acme/library" +} \ No newline at end of file diff --git a/spec/Composer/stubs/libs/library2/composer.json b/spec/Composer/stubs/libs/library2/composer.json new file mode 100644 index 0000000..4211d8a --- /dev/null +++ b/spec/Composer/stubs/libs/library2/composer.json @@ -0,0 +1,3 @@ +{ + "name": "acme/library2" +} \ No newline at end of file diff --git a/spec/Composer/stubs/project-with-path-wildcard/.gitignore b/spec/Composer/stubs/project-with-path-wildcard/.gitignore new file mode 100644 index 0000000..4788fc4 --- /dev/null +++ b/spec/Composer/stubs/project-with-path-wildcard/.gitignore @@ -0,0 +1 @@ +.studio/ \ No newline at end of file diff --git a/spec/Composer/stubs/project-with-path-wildcard/studio.json b/spec/Composer/stubs/project-with-path-wildcard/studio.json new file mode 100644 index 0000000..c9c595c --- /dev/null +++ b/spec/Composer/stubs/project-with-path-wildcard/studio.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "paths": [ + "../libs/*" + ] +} diff --git a/spec/Composer/stubs/project-with-path/.gitignore b/spec/Composer/stubs/project-with-path/.gitignore new file mode 100644 index 0000000..4788fc4 --- /dev/null +++ b/spec/Composer/stubs/project-with-path/.gitignore @@ -0,0 +1 @@ +.studio/ \ No newline at end of file diff --git a/spec/Composer/stubs/project-with-path/studio.json b/spec/Composer/stubs/project-with-path/studio.json new file mode 100644 index 0000000..91bd90a --- /dev/null +++ b/spec/Composer/stubs/project-with-path/studio.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "paths": [ + "../libs/library" + ] +} diff --git a/spec/Composer/stubs/project-with-unload-wildcard/.studio/studio.json b/spec/Composer/stubs/project-with-unload-wildcard/.studio/studio.json new file mode 100644 index 0000000..c9c595c --- /dev/null +++ b/spec/Composer/stubs/project-with-unload-wildcard/.studio/studio.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "paths": [ + "../libs/*" + ] +} diff --git a/spec/Composer/stubs/project-with-unload-wildcard/studio.json b/spec/Composer/stubs/project-with-unload-wildcard/studio.json new file mode 100644 index 0000000..d5ae520 --- /dev/null +++ b/spec/Composer/stubs/project-with-unload-wildcard/studio.json @@ -0,0 +1,4 @@ +{ + "version": 2, + "paths": [] +} diff --git a/spec/Composer/stubs/project-with-unload/.studio/studio.json b/spec/Composer/stubs/project-with-unload/.studio/studio.json new file mode 100644 index 0000000..91bd90a --- /dev/null +++ b/spec/Composer/stubs/project-with-unload/.studio/studio.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "paths": [ + "../libs/library" + ] +} diff --git a/spec/Composer/stubs/project-with-unload/studio.json b/spec/Composer/stubs/project-with-unload/studio.json new file mode 100644 index 0000000..d5ae520 --- /dev/null +++ b/spec/Composer/stubs/project-with-unload/studio.json @@ -0,0 +1,4 @@ +{ + "version": 2, + "paths": [] +} diff --git a/spec/Composer/stubs/project-without-path/.gitignore b/spec/Composer/stubs/project-without-path/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Composer/StudioPlugin.php b/src/Composer/StudioPlugin.php index c1b3b41..b6a9e04 100644 --- a/src/Composer/StudioPlugin.php +++ b/src/Composer/StudioPlugin.php @@ -3,14 +3,25 @@ namespace Studio\Composer; use Composer\Composer; +use Composer\Downloader\DownloadManager; use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\Installer\InstallationManager; use Composer\IO\IOInterface; +use Composer\Json\JsonFile; +use Composer\Package\Loader\ArrayLoader; +use Composer\Package\Package; +use Composer\Package\RootPackageInterface; use Composer\Plugin\PluginInterface; -use Composer\Repository\PathRepository; +use Composer\Repository\WritableRepositoryInterface; use Composer\Script\ScriptEvents; +use Composer\Util\Filesystem; use Studio\Config\Config; -use Studio\Config\FileStorage; +/** + * Class StudioPlugin + * + * @package Studio\Composer + */ class StudioPlugin implements PluginInterface, EventSubscriberInterface { /** @@ -23,42 +34,230 @@ class StudioPlugin implements PluginInterface, EventSubscriberInterface */ protected $io; + /** + * @var Filesystem + */ + protected $filesystem; + + /** + * @var DownloadManager + */ + protected $downloadManager; + + /** + * @var InstallationManager + */ + protected $installationManager; + + /** + * @var RootPackageInterface + */ + protected $rootPackage; + + /** + * @var WritableRepositoryInterface + */ + private $localRepository; + + /** + * StudioPlugin constructor. + * + * @param Filesystem|null $filesystem + */ + public function __construct(Filesystem $filesystem = null) + { + $this->filesystem = $filesystem ?: new Filesystem(); + } + + /** + * @param Composer $composer + * @param IOInterface $io + */ public function activate(Composer $composer, IOInterface $io) { $this->composer = $composer; $this->io = $io; + $this->installationManager = $composer->getInstallationManager(); + $this->downloadManager = $composer->getDownloadManager(); + $this->rootPackage = $composer->getPackage(); + $this->localRepository = $composer->getRepositoryManager()->getLocalRepository(); } + /** + * @return array + */ public static function getSubscribedEvents() { return [ - ScriptEvents::PRE_INSTALL_CMD => 'registerStudioPackages', - ScriptEvents::PRE_UPDATE_CMD => 'registerStudioPackages', + ScriptEvents::PRE_UPDATE_CMD => 'unlinkStudioPackages', + ScriptEvents::POST_UPDATE_CMD => 'symlinkStudioPackages', + ScriptEvents::POST_INSTALL_CMD => 'symlinkStudioPackages', + ScriptEvents::PRE_AUTOLOAD_DUMP => 'symlinkStudioPackages' ]; } /** - * Register all managed paths with Composer. + * Symlink all managed paths by studio + * + * This happens just before the autoload generator kicks in except with --no-autoloader + * In that case we create the symlinks on the POST_UPDATE, POST_INSTALL events * - * This function configures Composer to treat all Studio-managed paths as local path repositories, so that packages - * therein will be symlinked directly. */ - public function registerStudioPackages() + public function symlinkStudioPackages() { - $repoManager = $this->composer->getRepositoryManager(); - $composerConfig = $this->composer->getConfig(); - + $studioDir = realpath($this->rootPackage->getTargetDir()) . DIRECTORY_SEPARATOR . '.studio'; foreach ($this->getManagedPaths() as $path) { - $this->io->writeError("[Studio] Loading path $path"); + $resolvedPaths = $this->resolvePath($path); + + foreach ($resolvedPaths as $resolvedPath) { + $package = $this->createPackageForPath($resolvedPath); + if (!$package || !$this->isLocalRepositoryPackage($package)) { + continue; + } + + $destination = $this->installationManager->getInstallPath($package); + + // Creates the symlink to the package + if (!$this->filesystem->isSymlinkedDirectory($destination) && + !$this->filesystem->isJunction($destination) + ) { + $this->io->write("[Studio] Creating link to $resolvedPath for package " . $package->getName()); + + // Create copy of original in the `.studio` directory, + // we use the original on the next `composer update` + if (is_dir($destination)) { + $copyPath = $studioDir . DIRECTORY_SEPARATOR . $package->getName(); + $this->filesystem->ensureDirectoryExists($copyPath); + $this->filesystem->copyThenRemove($destination, $copyPath); + } + + // Download the managed package from its path with the composer downloader + $pathDownloader = $this->downloadManager->getDownloader('path'); + $pathDownloader->download($package, $destination); + } + } - $repoManager->prependRepository(new PathRepository( - ['url' => $path], - $this->io, - $composerConfig - )); + } + + // ensure the `.studio` directory only if we manage paths. + // without this check studio will create the `.studio` directory + // in all projects where composer is used + if (count($this->getManagedPaths())) { + $this->filesystem->ensureDirectoryExists('.studio'); + } + + // if we have managed paths or did have we copy the current studio.json + if (count($this->getManagedPaths()) > 0 || + count($this->getPreviouslyManagedPaths()) > 0 + ) { + // If we have the current studio.json copy it to the .studio directory + $studioFile = realpath($this->rootPackage->getTargetDir()) . DIRECTORY_SEPARATOR . 'studio.json'; + if (file_exists($studioFile)) { + copy($studioFile, $studioDir . DIRECTORY_SEPARATOR . 'studio.json'); + } } } + /** + * Removes all symlinks managed by studio + * + */ + public function unlinkStudioPackages() + { + $studioDir = realpath($this->rootPackage->getTargetDir()) . DIRECTORY_SEPARATOR . '.studio'; + $paths = array_merge($this->getPreviouslyManagedPaths(), $this->getManagedPaths()); + + foreach ($paths as $path) { + $resolvedPaths = $this->resolvePath($path); + + foreach ($resolvedPaths as $resolvedPath) { + $package = $this->createPackageForPath($resolvedPath); + if ($package == null) { + continue; + } + + $destination = $this->installationManager->getInstallPath($package); + + if ($this->filesystem->isSymlinkedDirectory($destination) || + $this->filesystem->isJunction($destination) + ) { + $this->io->write("[Studio] Removing linked path $resolvedPath for package " . $package->getName()); + $this->filesystem->removeDirectory($destination); + + // If we have an original copy move it back + $copyPath = $studioDir . DIRECTORY_SEPARATOR . $package->getName(); + if (is_dir($copyPath)) { + $this->filesystem->copyThenRemove($copyPath, $destination); + } + } + } + } + } + + /** + * Creates package from given path + * + * @param string $path + * @return Package + */ + private function createPackageForPath($path) + { + $composerJson = $path . DIRECTORY_SEPARATOR . 'composer.json'; + + if (is_readable($composerJson)) { + $json = (new JsonFile($composerJson))->read(); + $json['version'] = 'dev-master'; + + // branch alias won't work, otherwise the ArrayLoader::load won't return an instance of CompletePackage + unset($json['extra']['branch-alias']); + + $loader = new ArrayLoader(); + $package = $loader->load($json); + $package->setDistUrl($path); + + return $package; + } + + return NULL; + } + + + /** + * Resolve path with glob to an array of existing paths. + * + * @param string $path + * @return string[] + */ + private function resolvePath($path) + { + /** @var string[] $paths */ + $paths = []; + + $realPaths = glob($path); + foreach ($realPaths as $realPath) { + if (!in_array($realPath, $paths)) { + $paths[] = $realPath; + } + } + + return $paths; + } + + /** + * Check if this package is a dependency (transitive or not) of the root package. + * + * @param Package $package + * @return bool + */ + private function isLocalRepositoryPackage($package) { + foreach ($this->localRepository->getPackages() as $localRepositoryPackage) { + if ($localRepositoryPackage->getName() == $package->getName()) { + return true; + } + } + return false; + } + /** * Get the list of paths that are being managed by Studio. * @@ -66,8 +265,21 @@ public function registerStudioPackages() */ private function getManagedPaths() { - $targetDir = realpath($this->composer->getPackage()->getTargetDir()); - $config = Config::make("{$targetDir}/studio.json"); + $targetDir = realpath($this->rootPackage->getTargetDir()); + $config = Config::make($targetDir . DIRECTORY_SEPARATOR . 'studio.json'); + + return $config->getPaths(); + } + + /** + * Get last known managed paths by studio + * + * @return array + */ + private function getPreviouslyManagedPaths() + { + $targetDir = realpath($this->rootPackage->getTargetDir()) . DIRECTORY_SEPARATOR . '.studio'; + $config = Config::make($targetDir . DIRECTORY_SEPARATOR . 'studio.json'); return $config->getPaths(); }