diff --git a/README.md b/README.md index 3bdbc73..877c40f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Instead of installing the packages you're working on from the Packagist reposito Under the hood, it uses Composer's [path repositories](https://getcomposer.org/doc/05-repositories.md#path) to do so. As a result, you won't have to develop in the `vendor` directory. +To maintain the integrity of the `composer.lock` file, studio creates a `.studio` directory to keep the original packages from Packagist. + Studio also knows how to configure development tools that might be part of your workflow. This includes the following: @@ -65,16 +67,19 @@ And finally, tell Studio to set up the symlinks: If all goes well, you should now see a brief message along the following as part of Composer's output: -> [Studio] Loading path installer +> [Studio] Creating link to path/to/world-domination for package my/world-domination This is what will happen under the hood: -1. Composer begins checking dependencies for updates. -2. Studio jumps in and informs Composer to prefer packages from the directories listed in the `studio.json` file over downloading them from Packagist. -3. Composer symlinks these packages into the `vendor` directory or any other appropriate place (e.g. for [custom installers](https://getcomposer.org/doc/articles/custom-installers.md)). +1. Studio will restore all original packages from the `.studio` directory that were managed by studio. +2. Composer begins checking dependencies for updates. +3. When Composer installed all packages into the `vendor` directory studio jumps in and re installs all packages + listed in the `studio.json` with the [path repository](https://getcomposer.org/doc/05-repositories.md#path) installer. + The original packages are stored in the `.studio` directory. +4. Composer symlinks these packages into the `vendor` directory or any other appropriate place (e.g. for [custom installers](https://getcomposer.org/doc/articles/custom-installers.md)). Thus, to your application, these packages will behave just like "normal" Composer packages. -4. Composer generates proper autoloading rules for the Studio packages. -5. For non-Studio packages, Composer works as always. +5. Composer generates proper autoloading rules for the Studio packages. +6. For non-Studio packages, Composer works as always. **Pro tip:** If you keep all your libraries in one directory, you can let Studio find all of them by using a wildcard: diff --git a/spec/Composer/StudioPluginSpec.php b/spec/Composer/StudioPluginSpec.php new file mode 100644 index 0000000..ae66149 --- /dev/null +++ b/spec/Composer/StudioPluginSpec.php @@ -0,0 +1,171 @@ +shouldHaveType('Studio\Composer\StudioPlugin'); + } + + function it_is_activatable(Composer $composer, IOInterface $io) + { + $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) + { + // 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'); + + // Construct + $this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn(null); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn($rootPackage); + $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) + { + // 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'); + + // Construct + $this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn(null); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn($rootPackage); + $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 + ) { + // 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'); + + // Construct + //$this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn($installationManager); + $composer->getDownloadManager()->willReturn($downloadManager); + $composer->getPackage()->willReturn($rootPackage); + $rootPackage->getTargetDir()->willReturn(getcwd()); + $downloadManager->getDownloader('path') + ->willReturn($pathDownloader) + ->shouldBeCalled(); + + $io->write('[Studio] Creating link to library for package acme/library')->shouldBeCalled(); + + // 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 + ) { + // 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'); + + // Construct + $this->beConstructedWith($filesystem); + + // Mock methods + $composer->getInstallationManager()->willReturn($installationManager); + $composer->getDownloadManager()->willReturn(null); + $composer->getPackage()->willReturn($rootPackage); + $rootPackage->getTargetDir()->willReturn(getcwd()); + $filesystem->isSymlinkedDirectory(null)->willReturn(true)->shouldBeCalled(); + $filesystem->removeDirectory(null)->shouldBeCalled(); + + $io->write('[Studio] Removing linked path library for package acme/library')->shouldBeCalled(); + + // Test + $this->activate($composer, $io); + $this->unlinkStudioPackages(); + } +} diff --git a/spec/Composer/stubs/project-with-path/library/composer.json b/spec/Composer/stubs/project-with-path/library/composer.json new file mode 100644 index 0000000..ae0bdf7 --- /dev/null +++ b/spec/Composer/stubs/project-with-path/library/composer.json @@ -0,0 +1,3 @@ +{ + "name": "acme/library" +} \ 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..5ce1339 --- /dev/null +++ b/spec/Composer/stubs/project-with-path/studio.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "paths": [ + "library" + ] +} 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..5ce1339 --- /dev/null +++ b/spec/Composer/stubs/project-with-unload/.studio/studio.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "paths": [ + "library" + ] +} diff --git a/spec/Composer/stubs/project-with-unload/library/composer.json b/spec/Composer/stubs/project-with-unload/library/composer.json new file mode 100644 index 0000000..ae0bdf7 --- /dev/null +++ b/spec/Composer/stubs/project-with-unload/library/composer.json @@ -0,0 +1,3 @@ +{ + "name": "acme/library" +} \ No newline at end of file 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..5d07940 --- /dev/null +++ b/spec/Composer/stubs/project-with-unload/studio.json @@ -0,0 +1,4 @@ +{ + "version": 2, + "paths": [] +} diff --git a/src/Composer/StudioPlugin.php b/src/Composer/StudioPlugin.php index c1b3b41..225d34a 100644 --- a/src/Composer/StudioPlugin.php +++ b/src/Composer/StudioPlugin.php @@ -3,14 +3,24 @@ 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\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 +33,166 @@ class StudioPlugin implements PluginInterface, EventSubscriberInterface */ protected $io; + /** + * @var Filesystem + */ + protected $filesystem; + + /** + * @var DownloadManager + */ + protected $downloadManager; + + /** + * @var InstallationManager + */ + protected $installationManager; + + /** + * @var RootPackageInterface + */ + protected $rootPackage; + + /** + * 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(); } + /** + * @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"); + $package = $this->createPackageForPath($path); + $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 $path 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); + } - $repoManager->prependRepository(new PathRepository( - ['url' => $path], - $this->io, - $composerConfig - )); + // Download the managed package from its path with the composer downloader + $pathDownloader = $this->downloadManager->getDownloader('path'); + $pathDownloader->download($package, $destination); + } + } + + // 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) { + $package = $this->createPackageForPath($path); + $destination = $this->installationManager->getInstallPath($package); + + if ($this->filesystem->isSymlinkedDirectory($destination) || + $this->filesystem->isJunction($destination) + ) { + $this->io->write("[Studio] Removing linked path $path 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) + { + $json = (new JsonFile( + realpath($path . DIRECTORY_SEPARATOR . 'composer.json') + ))->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; + } + /** * Get the list of paths that are being managed by Studio. * @@ -66,8 +200,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(); }