Browse Source

Merge pull request #736 from Seldaek/require-update

Require command & update <package>
Nils Adermann 12 years ago
parent
commit
242323cba4

+ 2 - 0
CHANGELOG.md

@@ -1,6 +1,8 @@
 * 1.0.0-alpha4
 
   * Schema: Added references for dev versions, requiring `dev-master#abcdef` for example will force the abcdef commit
+  * Added `require` command to add a package to your requirements and install it
+  * Added a whitelist to `update`. Calling `composer update foo/bar foo/baz` allows you to update only those packages
   * Added caching of GitHub metadata (faster startup time with custom GitHub VCS repos)
   * Added support for file:// URLs to GitDriver
   * Added --dev flag to `create-project` command

+ 24 - 0
doc/03-cli.md

@@ -53,12 +53,36 @@ In order to get the latest versions of the dependencies and to update the
 This will resolve all dependencies of the project and write the exact versions
 into `composer.lock`.
 
+If you just want to update a few packages and not all, you can list them as such:
+
+    $ php composer.phar update vendor/package vendor/package2
+
 ### Options
 
 * **--prefer-source:** Install packages from `source` when available.
 * **--dry-run:** Simulate the command without actually doing anything.
 * **--dev:** Install packages listed in `require-dev`.
 
+## require
+
+The `require` command adds new packages to the `composer.json` file from 
+the current directory.
+
+    $ php composer.phar require
+
+After adding/changing the requirements, the modified requirements will be
+installed or updated. 
+
+If you do not want to choose requirements interactively, you can just pass them
+to the command.
+
+    $ php composer.phar require vendor/package:2.* vendor/package2:dev-master
+
+### Options
+
+* **--prefer-source:** Install packages from `source` when available.
+* **--dev:** Add packages to `require-dev`.
+
 ## search
 
 The search command allows you to search through the current project's package

+ 27 - 6
src/Composer/Command/InitCommand.php

@@ -60,7 +60,8 @@ class InitCommand extends Command
                 new InputOption('author', null, InputOption::VALUE_NONE, 'Author name of package'),
                 // new InputOption('version', null, InputOption::VALUE_NONE, 'Version of package'),
                 new InputOption('homepage', null, InputOption::VALUE_NONE, 'Homepage of package'),
-                new InputOption('require', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'An array required packages'),
+                new InputOption('require', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'),
+                new InputOption('require-dev', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require for development with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'),
             ))
             ->setHelp(<<<EOT
 The <info>init</info> command creates a basic composer.json file
@@ -216,10 +217,15 @@ EOT
         ));
 
         $requirements = array();
-        if ($dialog->askConfirmation($output, $dialog->getQuestion('Would you like to define your dependencies interactively', 'yes', '?'), true)) {
-            $requirements = $this->determineRequirements($input, $output);
+        if ($dialog->askConfirmation($output, $dialog->getQuestion('Would you like to define your dependencies (require) interactively', 'yes', '?'), true)) {
+            $requirements = $this->determineRequirements($input, $output, $input->getOption('require'));
         }
         $input->setOption('require', $requirements);
+        $devRequirements = array();
+        if ($dialog->askConfirmation($output, $dialog->getQuestion('Would you like to define your dev dependencies (require-dev) interactively', 'yes', '?'), true)) {
+            $devRequirements = $this->determineRequirements($input, $output, $input->getOption('require-dev'));
+        }
+        $input->setOption('require-dev', $devRequirements);
     }
 
     protected function findPackages($name)
@@ -246,12 +252,27 @@ EOT
         return $packages;
     }
 
-    protected function determineRequirements(InputInterface $input, OutputInterface $output)
+    protected function determineRequirements(InputInterface $input, OutputInterface $output, $requires = array())
     {
         $dialog = $this->getHelperSet()->get('dialog');
         $prompt = $dialog->getQuestion('Search for a package', false, ':');
 
-        $requires = $input->getOption('require') ?: array();
+        if ($requires) {
+            foreach ($requires as $key => $requirement) {
+                $requires[$key] = preg_replace('{^([^=: ]+)[=: ](.*)$}', '$1 $2', $requirement);
+                if (false === strpos($requires[$key], ' ') && $input->isInteractive()) {
+                    $question = $dialog->getQuestion('Please provide a version constraint for the '.$requirement.' requirement');
+                    if ($constraint = $dialog->ask($output, $question)) {
+                        $requires[$key] .= ' ' . $constraint;
+                    }
+                }
+                if (false === strpos($requires[$key], ' ')) {
+                    throw new \InvalidArgumentException('The requirement '.$requirement.' must contain a version constraint');
+                }
+            }
+
+            return $requires;
+        }
 
         while (null !== $package = $dialog->ask($output, $prompt)) {
             $matches = $this->findPackages($package);
@@ -287,7 +308,7 @@ EOT
                     return sprintf('%s %s', $package->getName(), $package->getPrettyVersion());
                 };
 
-                $package = $dialog->askAndValidate($output, $dialog->getQuestion('Enter package # to add, or a <package> <version> couple if it is not listed', false, ':'), $validator, 3);
+                $package = $dialog->askAndValidate($output, $dialog->getQuestion('Enter package # to add, or a "[package] [version]" couple if it is not listed', false, ':'), $validator, 3);
 
                 if (false !== $package) {
                     $requires[] = $package;

+ 123 - 0
src/Composer/Command/RequireCommand.php

@@ -0,0 +1,123 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Command;
+
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Composer\Factory;
+use Composer\Installer;
+use Composer\Json\JsonFile;
+use Composer\Json\JsonManipulator;
+use Composer\Json\JsonValidationException;
+use Composer\Util\RemoteFilesystem;
+
+/**
+ * @author Jérémy Romey <jeremy@free-agent.fr>
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class RequireCommand extends InitCommand
+{
+    protected function configure()
+    {
+        $this
+            ->setName('require')
+            ->setDescription('Adds required packages to your composer.json and installs them')
+            ->setDefinition(array(
+                new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Required package with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'),
+                new InputOption('dev', null, InputOption::VALUE_NONE, 'Add requirement to require-dev.'),
+                new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'),
+            ))
+            ->setHelp(<<<EOT
+The require command adds required packages to your composer.json and installs them
+
+EOT
+            )
+        ;
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $factory = new Factory;
+        $file = $factory->getComposerFile();
+
+        if (!file_exists($file)) {
+            $output->writeln('<error>'.$file.' not found.</error>');
+            return 1;
+        }
+        if (!is_readable($file)) {
+            $output->writeln('<error>'.$file.' is not readable.</error>');
+            return 1;
+        }
+
+        $dialog = $this->getHelperSet()->get('dialog');
+
+        $json = new JsonFile($file);
+        $composer = $json->read();
+
+        $requirements = $this->determineRequirements($input, $output, $input->getArgument('packages'));
+
+        $requireKey = $input->getOption('dev') ? 'require-dev' : 'require';
+        $baseRequirements = array_key_exists($requireKey, $composer) ? $composer[$requireKey] : array();
+        $requirements = $this->formatRequirements($requirements);
+
+        if (!$this->updateFileCleanly($json, $baseRequirements, $requirements, $requireKey)) {
+            foreach ($requirements as $package => $version) {
+                $baseRequirements[$package] = $version;
+            }
+
+            $composer[$requireKey] = $baseRequirements;
+            $json->write($composer);
+        }
+
+        $output->writeln('<info>'.$file.' has been updated</info>');
+
+        // Update packages
+        $composer = $this->getComposer();
+        $io = $this->getIO();
+        $install = Installer::create($io, $composer);
+
+        $install
+            ->setVerbose($input->getOption('verbose'))
+            ->setPreferSource($input->getOption('prefer-source'))
+            ->setDevMode($input->getOption('dev'))
+            ->setUpdate(true)
+            ->setUpdateWhitelist($requirements);
+        ;
+
+        return $install->run() ? 0 : 1;
+    }
+
+    private function updateFileCleanly($json, array $base, array $new, $requireKey)
+    {
+        $contents = file_get_contents($json->getPath());
+
+        $manipulator = new JsonManipulator($contents);
+
+        foreach ($new as $package => $constraint) {
+            if (!$manipulator->addLink($requireKey, $package, $constraint)) {
+                return false;
+            }
+        }
+
+        file_put_contents($json->getPath(), $manipulator->getContents());
+
+        return true;
+    }
+
+    protected function interact(InputInterface $input, OutputInterface $output)
+    {
+        return;
+    }
+}

+ 3 - 0
src/Composer/Command/UpdateCommand.php

@@ -15,6 +15,7 @@ namespace Composer\Command;
 use Composer\Installer;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Output\OutputInterface;
 
 /**
@@ -28,6 +29,7 @@ class UpdateCommand extends Command
             ->setName('update')
             ->setDescription('Updates your dependencies to the latest version, and updates the composer.lock file.')
             ->setDefinition(array(
+                new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Packages that should be updated, if not provided all packages are.'),
                 new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'),
                 new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Outputs the operations but will not execute anything (implicitly enables --verbose).'),
                 new InputOption('dev', null, InputOption::VALUE_NONE, 'Enables installation of dev-require packages.'),
@@ -58,6 +60,7 @@ EOT
             ->setDevMode($input->getOption('dev'))
             ->setRunScripts(!$input->getOption('no-scripts'))
             ->setUpdate(true)
+            ->setUpdateWhitelist($input->getArgument('packages'))
         ;
 
         return $install->run() ? 0 : 1;

+ 15 - 12
src/Composer/Console/Application.php

@@ -64,7 +64,6 @@ class Application extends BaseApplication
      */
     public function doRun(InputInterface $input, OutputInterface $output)
     {
-        $this->registerCommands();
         $this->io = new ConsoleIO($input, $output, $this->getHelperSet());
 
         if (version_compare(PHP_VERSION, '5.3.2', '<')) {
@@ -106,21 +105,25 @@ class Application extends BaseApplication
     /**
      * Initializes all the composer commands
      */
-    protected function registerCommands()
+    protected function getDefaultCommands()
     {
-        $this->add(new Command\AboutCommand());
-        $this->add(new Command\DependsCommand());
-        $this->add(new Command\InitCommand());
-        $this->add(new Command\InstallCommand());
-        $this->add(new Command\CreateProjectCommand());
-        $this->add(new Command\UpdateCommand());
-        $this->add(new Command\SearchCommand());
-        $this->add(new Command\ValidateCommand());
-        $this->add(new Command\ShowCommand());
+        $commands = parent::getDefaultCommands();
+        $commands[] = new Command\AboutCommand();
+        $commands[] = new Command\DependsCommand();
+        $commands[] = new Command\InitCommand();
+        $commands[] = new Command\InstallCommand();
+        $commands[] = new Command\CreateProjectCommand();
+        $commands[] = new Command\UpdateCommand();
+        $commands[] = new Command\SearchCommand();
+        $commands[] = new Command\ValidateCommand();
+        $commands[] = new Command\ShowCommand();
+        $commands[] = new Command\RequireCommand();
 
         if ('phar:' === substr(__FILE__, 0, 5)) {
-            $this->add(new Command\SelfUpdateCommand());
+            $commands[] = new Command\SelfUpdateCommand();
         }
+
+        return $commands;
     }
 
     /**

+ 6 - 1
src/Composer/Factory.php

@@ -62,6 +62,11 @@ class Factory
         return $config;
     }
 
+    public function getComposerFile()
+    {
+        return getenv('COMPOSER') ?: 'composer.json';
+    }
+
     /**
      * Creates a Composer instance
      *
@@ -73,7 +78,7 @@ class Factory
     {
         // load Composer configuration
         if (null === $localConfig) {
-            $localConfig = getenv('COMPOSER') ?: 'composer.json';
+            $localConfig = $this->getComposerFile();
         }
 
         if (is_string($localConfig)) {

+ 84 - 1
src/Composer/Installer.php

@@ -89,6 +89,7 @@ class Installer
     protected $verbose = false;
     protected $update = false;
     protected $runScripts = true;
+    protected $updateWhitelist = null;
 
     /**
      * @var array
@@ -219,6 +220,8 @@ class Installer
             $stabilityFlags = $this->locker->getStabilityFlags();
         }
 
+        $this->whitelistUpdateDependencies($localRepo, $devMode);
+
         // creating repository pool
         $pool = new Pool($minimumStability, $stabilityFlags);
         $pool->addRepository($installedRepo);
@@ -275,8 +278,11 @@ class Installer
         // fix the version of all installed packages (+ platform) that are not
         // in the current local repo to prevent rogue updates (e.g. non-dev
         // updating when in dev)
+        //
+        // if the updateWhitelist is enabled, packages not in it are also fixed
+        // to their currently installed version
         foreach ($installedRepo->getPackages() as $package) {
-            if ($package->getRepository() === $localRepo) {
+            if ($package->getRepository() === $localRepo && (!$this->updateWhitelist || $this->isUpdateable($package))) {
                 continue;
             }
 
@@ -331,6 +337,11 @@ class Installer
             } else {
                 // force update to latest on update
                 if ($this->update) {
+                    // skip package if the whitelist is enabled and it is not in it
+                    if ($this->updateWhitelist && !$this->isUpdateable($package)) {
+                        continue;
+                    }
+
                     $newPackage = $this->repositoryManager->findPackage($package->getName(), $package->getVersion());
                     if ($newPackage && $newPackage->getSourceReference() !== $package->getSourceReference()) {
                         $operations[] = new UpdateOperation($package, $newPackage);
@@ -441,6 +452,64 @@ class Installer
         return $aliases;
     }
 
+    private function isUpdateable(PackageInterface $package)
+    {
+        if (!$this->updateWhitelist) {
+            throw new \LogicException('isUpdateable should only be called when a whitelist is present');
+        }
+
+        return isset($this->updateWhitelist[$package->getName()]);
+    }
+
+    /**
+     * Adds all dependencies of the update whitelist to the whitelist, too.
+     *
+     * @param RepositoryInterface $localRepo
+     * @param boolean $devMode
+     */
+    private function whitelistUpdateDependencies($localRepo, $devMode)
+    {
+        if (!$this->updateWhitelist) {
+            return;
+        }
+
+        $pool = new Pool;
+        $pool->addRepository($localRepo);
+
+        $seen = array();
+
+        foreach ($this->updateWhitelist as $packageName => $void) {
+            $packageQueue = new \SplQueue;
+
+            foreach ($pool->whatProvides($packageName) as $depPackage) {
+                $packageQueue->enqueue($depPackage);
+            }
+
+            while (!$packageQueue->isEmpty()) {
+                $package = $packageQueue->dequeue();
+                if (isset($seen[$package->getId()])) {
+                    continue;
+                }
+
+                $seen[$package->getId()] = true;
+                $this->updateWhitelist[$package->getName()] = true;
+
+                $requires = $package->getRequires();
+                if ($devMode) {
+                    $requires = array_merge($requires, $package->getDevRequires());
+                }
+
+                foreach ($requires as $require) {
+                    $requirePackages = $pool->whatProvides($require->getTarget());
+
+                    foreach ($requirePackages as $requirePackage) {
+                        $packageQueue->enqueue($requirePackage);
+                    }
+                }
+            }
+        }
+    }
+
     /**
      * Create Installer
      *
@@ -551,4 +620,18 @@ class Installer
 
         return $this;
     }
+
+    /**
+     * restrict the update operation to a few packages, all other packages
+     * that are already installed will be kept at their current version
+     *
+     * @param  array     $packages
+     * @return Installer
+     */
+    public function setUpdateWhitelist(array $packages)
+    {
+        $this->updateWhitelist = array_flip(array_map('strtolower', $packages));
+
+        return $this;
+    }
 }

+ 121 - 0
src/Composer/Json/JsonManipulator.php

@@ -0,0 +1,121 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Json;
+
+/**
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class JsonManipulator
+{
+    private $contents;
+    private $newline;
+    private $indent;
+
+    public function __construct($contents)
+    {
+        $contents = trim($contents);
+        if (!preg_match('#^\{(.*)\}$#s', $contents)) {
+            throw new \InvalidArgumentException('The json file must be an object ({})');
+        }
+        $this->newline = false !== strpos("\r\n", $contents) ? "\r\n": "\n";
+        $this->contents = $contents;
+        $this->detectIndenting();
+    }
+
+    public function getContents()
+    {
+        return $this->contents . $this->newline;
+    }
+
+    public function addLink($type, $package, $constraint)
+    {
+        // no link of that type yet
+        if (!preg_match('#"'.$type.'":\s*\{#', $this->contents)) {
+            $this->addMainKey($type, $this->format(array($package => $constraint)));
+
+            return true;
+        }
+
+        $linksRegex = '#("'.$type.'":\s*\{)([^}]+)(\})#s';
+        if (!preg_match($linksRegex, $this->contents, $match)) {
+            return false;
+        }
+
+        $links = $match[2];
+        $packageRegex = str_replace('/', '\\\\?/', preg_quote($package));
+
+        // link exists already
+        if (preg_match('{"'.$packageRegex.'"\s*:}i', $links)) {
+            $links = preg_replace('{"'.$packageRegex.'"(\s*:\s*)"[^"]+"}i', JsonFile::encode($package).'$1"'.$constraint.'"', $links);
+        } elseif (preg_match('#[^\s](\s*)$#', $links, $match)) {
+            // link missing but non empty links
+            $links = preg_replace(
+                '#'.$match[1].'$#',
+                ',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $match[1],
+                $links
+            );
+        } else {
+            // links empty
+            $links = $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $links;
+        }
+
+        $this->contents = preg_replace($linksRegex, '$1'.$links.'$3', $this->contents);
+
+        return true;
+    }
+
+    public function addMainKey($key, $content)
+    {
+        if (preg_match('#[^{\s](\s*)\}$#', $this->contents, $match)) {
+            $this->contents = preg_replace(
+                '#'.$match[1].'\}$#',
+                ',' . $this->newline . $this->indent . JsonFile::encode($key). ': '. $content . $this->newline . '}',
+                $this->contents
+            );
+        } else {
+            $this->contents = preg_replace(
+                '#\}$#',
+                $this->indent . JsonFile::encode($key). ': '.$content . $this->newline . '}',
+                $this->contents
+            );
+        }
+    }
+
+    protected function format($data)
+    {
+        if (is_array($data)) {
+            reset($data);
+
+            if (is_numeric(key($data))) {
+                return '['.implode(', ', $data).']';
+            }
+
+            $out = '{' . $this->newline;
+            foreach ($data as $key => $val) {
+                $elems[] = $this->indent . $this->indent . JsonFile::encode($key). ': '.$this->format($val);
+            }
+            return $out . implode(','.$this->newline, $elems) . $this->newline . $this->indent . '}';
+        }
+
+        return JsonFile::encode($data);
+    }
+
+    protected function detectIndenting()
+    {
+        if (preg_match('{^(\s+)"}', $this->contents, $match)) {
+            $this->indent = $match[1];
+        } else {
+            $this->indent = '    ';
+        }
+    }
+}

+ 3 - 1
tests/Composer/Test/Fixtures/installer/SAMPLE

@@ -10,5 +10,7 @@
 <installed.json file definition>
 --INSTALLED:DEV--
 <installed_dev.json file definition>
---EXPECT-- or --EXPECT:UPDATE-- or --EXPECT:DEV-- or --EXPECT:UPDATE:DEV--
+--RUN--
+install
+--EXPECT--
 <output (stringified operations)>

+ 2 - 0
tests/Composer/Test/Fixtures/installer/aliased-priority-conflicting.test

@@ -42,6 +42,8 @@ Aliases take precedence over default package even if default is selected
         "a/req": "dev-feature-foo as dev-master"
     }
 }
+--RUN--
+install
 --EXPECT--
 Marking a/req (dev-master feat.f) as installed, alias of a/req (dev-feature-foo feat.f)
 Installing a/req (dev-feature-foo feat.f)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/aliased-priority.test

@@ -44,6 +44,8 @@ Aliases take precedence over default package
         "a/c": "dev-feature-foo as dev-master"
     }
 }
+--RUN--
+install
 --EXPECT--
 Installing a/b (dev-master forked)
 Marking a/b (1.0.x-dev forked) as installed, alias of a/b (dev-master forked)

+ 3 - 1
tests/Composer/Test/Fixtures/installer/install-dev.test

@@ -18,6 +18,8 @@ Installs a package in dev env
         "a/b": "1.0.0"
     }
 }
---EXPECT:DEV--
+--RUN--
+install --dev
+--EXPECT--
 Installing a/a (1.0.0)
 Installing a/b (1.0.0)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/install-reference.test

@@ -17,5 +17,7 @@ Installs a dev package forcing it's reference
         "a/a": "dev-master#def000"
     }
 }
+--RUN--
+install
 --EXPECT--
 Installing a/a (dev-master def000)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/install-simple.test

@@ -14,5 +14,7 @@ Installs a simple package with exact match requirement
         "a/a": "1.0.0"
     }
 }
+--RUN--
+install
 --EXPECT--
 Installing a/a (1.0.0)

+ 3 - 1
tests/Composer/Test/Fixtures/installer/update-all.test

@@ -36,6 +36,8 @@ Updates updateable packages
 [
     { "name": "a/b", "version": "1.0.0" }
 ]
---EXPECT:UPDATE:DEV--
+--RUN--
+update --dev
+--EXPECT--
 Updating a/a (1.0.0) to a/a (1.0.1)
 Updating a/b (1.0.0) to a/b (2.0.0)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/update-reference.test

@@ -24,5 +24,7 @@ Updates a dev package forcing it's reference
         "source": { "reference": "abc123", "url": "", "type": "git" }
     }
 ]
+--RUN--
+install
 --EXPECT--
 Updating a/a (dev-master abc123) to a/a (dev-master def000)

+ 40 - 0
tests/Composer/Test/Fixtures/installer/update-whitelist.test

@@ -0,0 +1,40 @@
+--TEST--
+Update with a package whitelist only updates those packages and their dependencies if they are not present in composer.json
+--COMPOSER--
+{
+    "repositories": [
+        {
+            "type": "package",
+            "package": [
+                { "name": "fixed", "version": "1.1.0" },
+                { "name": "fixed", "version": "1.0.0" },
+                { "name": "whitelisted", "version": "1.1.0", "require": { "dependency": "1.1.0" } },
+                { "name": "whitelisted", "version": "1.0.0", "require": { "dependency": "1.0.0" } },
+                { "name": "dependency", "version": "1.1.0" },
+                { "name": "dependency", "version": "1.0.0" },
+                { "name": "unrelated", "version": "1.1.0", "require": { "unrelated-dependency": "1.*" }  },
+                { "name": "unrelated", "version": "1.0.0", "require": { "unrelated-dependency": "1.*" }  },
+                { "name": "unrelated-dependency", "version": "1.1.0" },
+                { "name": "unrelated-dependency", "version": "1.0.0" }
+            ]
+        }
+    ],
+    "require": {
+        "fixed": "1.*",
+        "whitelisted": "1.*",
+        "unrelated": "1.*"
+    }
+}
+--INSTALLED--
+[
+    { "name": "fixed", "version": "1.0.0" },
+    { "name": "whitelisted", "version": "1.0.0", "require": { "dependency": "1.0.0" } },
+    { "name": "dependency", "version": "1.0.0" },
+    { "name": "unrelated", "version": "1.0.0", "require": { "unrelated-dependency": "1.*" } },
+    { "name": "unrelated-dependency", "version": "1.0.0" }
+]
+--RUN--
+update whitelisted
+--EXPECT--
+Updating dependency (1.0.0) to dependency (1.1.0)
+Updating whitelisted (1.0.0) to whitelisted (1.1.0)

+ 31 - 11
tests/Composer/Test/InstallerTest.php

@@ -12,6 +12,7 @@
 namespace Composer\Test;
 
 use Composer\Installer;
+use Composer\Console\Application;
 use Composer\Config;
 use Composer\Json\JsonFile;
 use Composer\Repository\ArrayRepository;
@@ -24,6 +25,8 @@ use Composer\Test\Mock\FactoryMock;
 use Composer\Test\Mock\InstalledFilesystemRepositoryMock;
 use Composer\Test\Mock\InstallationManagerMock;
 use Composer\Test\Mock\WritableRepositoryMock;
+use Symfony\Component\Console\Input\StringInput;
+use Symfony\Component\Console\Output\StreamOutput;
 
 class InstallerTest extends TestCase
 {
@@ -121,7 +124,7 @@ class InstallerTest extends TestCase
     /**
      * @dataProvider getIntegrationTests
      */
-    public function testIntegration($file, $message, $condition, $composer, $lock, $installed, $installedDev, $update, $dev, $expect)
+    public function testIntegration($file, $message, $condition, $composer, $lock, $installed, $installedDev, $run, $expect)
     {
         if ($condition) {
             eval('$res = '.$condition.';');
@@ -177,14 +180,31 @@ class InstallerTest extends TestCase
             $autoloadGenerator
         );
 
-        $installer->setDevMode($dev)->setUpdate($update);
+        $application = new Application;
+        $application->get('install')->setCode(function ($input, $output) use ($installer) {
+            $installer->setDevMode($input->getOption('dev'));
 
-        $result = $installer->run();
-        $this->assertTrue($result, $output);
+            return $installer->run();
+        });
 
-        $expectedInstalled   = isset($options['install']) ? $options['install'] : array();
-        $expectedUpdated     = isset($options['update']) ? $options['update'] : array();
-        $expectedUninstalled = isset($options['uninstall']) ? $options['uninstall'] : array();
+        $application->get('update')->setCode(function ($input, $output) use ($installer) {
+            $installer
+                ->setDevMode($input->getOption('dev'))
+                ->setUpdate(true)
+                ->setUpdateWhitelist($input->getArgument('packages'));
+
+            return $installer->run();
+        });
+
+        if (!preg_match('{^(install|update)\b}', $run)) {
+            throw new \UnexpectedValueException('The run command only supports install and update');
+        }
+
+        $application->setAutoExit(false);
+        $appOutput = fopen('php://memory', 'w+');
+        $result = $application->run(new StringInput($run), new StreamOutput($appOutput));
+        fseek($appOutput, 0);
+        $this->assertEquals(0, $result, $output . stream_get_contents($appOutput));
 
         $installationManager = $composer->getInstallationManager();
         $this->assertSame($expect, implode("\n", $installationManager->getTrace()));
@@ -210,7 +230,8 @@ class InstallerTest extends TestCase
                 (?:--LOCK--\s*(?P<lock>'.$content.'))?\s*
                 (?:--INSTALLED--\s*(?P<installed>'.$content.'))?\s*
                 (?:--INSTALLED:DEV--\s*(?P<installedDev>'.$content.'))?\s*
-                --EXPECT(?P<update>:UPDATE)?(?P<dev>:DEV)?--\s*(?P<expect>.*?)\s*
+                --RUN--\s*(?P<run>.*?)\s*
+                --EXPECT--\s*(?P<expect>.*?)\s*
             $}xs';
 
             $installed = array();
@@ -231,8 +252,7 @@ class InstallerTest extends TestCase
                     if (!empty($match['installedDev'])) {
                         $installedDev = JsonFile::parseJson($match['installedDev']);
                     }
-                    $update = !empty($match['update']);
-                    $dev = !empty($match['dev']);
+                    $run = $match['run'];
                     $expect = $match['expect'];
                 } catch (\Exception $e) {
                     die(sprintf('Test "%s" is not valid: '.$e->getMessage(), str_replace($fixturesDir.'/', '', $file)));
@@ -241,7 +261,7 @@ class InstallerTest extends TestCase
                 die(sprintf('Test "%s" is not valid, did not match the expected format.', str_replace($fixturesDir.'/', '', $file)));
             }
 
-            $tests[] = array(str_replace($fixturesDir.'/', '', $file), $message, $condition, $composer, $lock, $installed, $installedDev, $update, $dev, $expect);
+            $tests[] = array(str_replace($fixturesDir.'/', '', $file), $message, $condition, $composer, $lock, $installed, $installedDev, $run, $expect);
         }
 
         return $tests;

+ 134 - 0
tests/Composer/Test/Json/JsonManipulatorTest.php

@@ -0,0 +1,134 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Test\Json;
+
+use Composer\Json\JsonManipulator;
+
+class JsonManipulatorTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @dataProvider linkProvider
+     */
+    public function testAddLink($json, $type, $package, $constraint, $expected)
+    {
+        $manipulator = new JsonManipulator($json);
+        $this->assertTrue($manipulator->addLink($type, $package, $constraint));
+        $this->assertEquals($expected, $manipulator->getContents());
+    }
+
+    public function linkProvider()
+    {
+        return array(
+            array(
+                '{
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require": {
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "foo": "bar"
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "foo": "bar",
+    "require": {
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require": {
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require": {
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require": {
+        "foo": "bar"
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require": {
+        "foo": "bar",
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor/baz": "baz"
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor\/baz": "baz"
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+        );
+    }
+}