From 7f678db046f544f8fe8473aef074e2a8271953f9 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 4 Mar 2016 14:15:40 +1300 Subject: [PATCH 1/2] API Implement merge command --- readme.md | 22 ++++ src/Application.php | 3 + src/Commands/Branch/Merge.php | 105 ++++++++++++++++++ src/Model/ChangeLogItem.php | 2 +- src/Model/Module.php | 153 ++++++++++++++++++++++++-- src/Model/Project.php | 25 ++++- src/Model/ReleaseVersion.php | 17 ++- src/Steps/Branch/MergeBranch.php | 165 +++++++++++++++++++++++++++++ src/Steps/Release/CreateBranch.php | 2 +- 9 files changed, 477 insertions(+), 17 deletions(-) create mode 100644 src/Commands/Branch/Merge.php create mode 100644 src/Steps/Branch/MergeBranch.php diff --git a/readme.md b/readme.md index a385bec..d7b802a 100644 --- a/readme.md +++ b/readme.md @@ -99,3 +99,25 @@ don't specify a list of modules then all modules will be translated. Specify 'in You can use `--push` option to push to origin at the end, or `--exclude` if your list of modules is the list to exclude. By default all modules are included, unless whitelisted or excluded. +## Branch helper + +When a release is done, the laborious task of merging up all changes begins. This is where it +can be handy to use the `branch:merge` command. This command has this syntax: + +`branch:merge [, ..] [--push] [--exclude] [-vvv]` + +This should be run in the project root, and will automatically merge each core module +from the `` branch into the `` branch. If either branches haven't yet been +pulled from upstream, then this command will automatically pull them down, and will also +refresh any existing branch before the merge. + +By default all modules specified in the root composer.json with `self.version` will be merged. +You can specify a single module (or set of modules) by adding additional arguments, which will +instead choose those modules. + +If you want the merged changes to be pushed up directly, then use the `--push` command to +trigger a push after the merge is complete. + +If a merge fails, or has unresolved conflicts, then a message will be displayed at the end of +execution with the list of directories that should be manually resolved. Once resolved (and +committed), just run the command again and it should continue. diff --git a/src/Application.php b/src/Application.php index 2c572c8..641fbc9 100644 --- a/src/Application.php +++ b/src/Application.php @@ -40,6 +40,9 @@ protected function getDefaultCommands() // Module commands $commands[] = new Commands\Module\Translate(); + // Branch commands + $commands[] = new Commands\Branch\Merge(); + return $commands; } } diff --git a/src/Commands/Branch/Merge.php b/src/Commands/Branch/Merge.php new file mode 100644 index 0000000..fc18e3c --- /dev/null +++ b/src/Commands/Branch/Merge.php @@ -0,0 +1,105 @@ +addArgument('from', InputArgument::REQUIRED, 'Branch name to merge from'); + $this->addArgument('to', InputArgument::REQUIRED, 'Branch name to merge to'); + + parent::configureOptions(); // TODO: Change the autogenerated stub + } + + protected function fire() + { + $directory = $this->getInputDirectory(); + $modules = $this->getInputModules(); + $listIsExclusive = $this->getInputExclude(); + $push = $this->getInputPush(); + $from = $this->getInputFrom(); + $to = $this->getInputTo(); + + // Bit of a sanity check on version + if(!$this->canMerge($from, $to)) { + throw new \InvalidArgumentException("{$to} seems like an older version that {$from}. Are you sure that's correct?"); + } + + $merge = new MergeBranch($this, $directory, $modules, $listIsExclusive, $from, $to, $push); + $merge->run($this->input, $this->output); + } + + /** + * Using SS workflow, can you merge $from into $to? + * + * @param string $from + * @param string $to + * @return bool + */ + protected function canMerge($from, $to) { + if($from === 'master') { + return false; + } + if($to === 'master') { + return true; + } + + // Allow if either $from or $to are non-numeric + if(!preg_match('/^(\d+)(.\d+)*$/', $from) || !preg_match('/^(\d+)(.\d+)*$/', $to)) { + return true; + } + + // Apply minor vs major rule (3.3 > 3 but not 3 > 3.3) + if(stripos($from, $to) === 0) { + return true; + } + if(stripos($to, $from) === 0) { + return false; + } + + // Otherwise, just make sure the to version is a higher value + return version_compare($to, $from, '>'); + } + + /** + * Get branch to merge from + * + * @return string + */ + protected function getInputFrom() + { + return $this->input->getArgument('from'); + } + + /** + * Get branch to merge to + * + * @return string + */ + protected function getInputTo() + { + return $this->input->getArgument('to'); + } +} diff --git a/src/Model/ChangeLogItem.php b/src/Model/ChangeLogItem.php index 8bdbbca..118038c 100644 --- a/src/Model/ChangeLogItem.php +++ b/src/Model/ChangeLogItem.php @@ -58,7 +58,7 @@ class ChangeLogItem '/^(ENHANCEMENT|ENHNACEMENT|FEATURE|NEW)\s?:?/i' ), 'Bugfixes' => array( - '/^(BUGFIX|BUGFUX|BUG|FIX|FIXED)\s?:?/', + '/^(BUGFIX|BUGFUX|BUG|FIX|FIXED|FIXING)\s?:?/', '/^(BUG FIX)\s?:?/' ) ); diff --git a/src/Model/Module.php b/src/Model/Module.php index 249a5b1..8c130e5 100644 --- a/src/Model/Module.php +++ b/src/Model/Module.php @@ -2,9 +2,12 @@ namespace SilverStripe\Cow\Model; +use Exception; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use InvalidArgumentException; +use Symfony\Component\Console\Logger\ConsoleLogger; +use Symfony\Component\Console\Output\OutputInterface; /** * A module installed in a project @@ -150,17 +153,24 @@ public function getLink() $name = $this->getName(); return "https://github.com/{$team}/silverstripe-{$name}/"; } - + /** * Get git repo for this module - * + * + * @param OutputInterface $output Optional output to log to * @return Repository */ - public function getRepository() + public function getRepository(OutputInterface $output = null) { - return new Repository($this->directory, array( + $repo = new Repository($this->directory, array( 'environment_variables' => array('HOME' => getenv('HOME')) )); + // Include logger if requested + if($output) { + $logger = new ConsoleLogger($output); + $repo->setLogger($logger); + } + return $repo; } /** @@ -177,14 +187,38 @@ public function getBranch() } /** - * Change to another branch, creating it if it doesn't exist + * Gets local branches * - * @param string $branch + * @param string $remote If specified, select from remote instead. If ignored, select local + * @return array */ - public function changeBranch($branch) - { - $repo = $this->getRepository(); - $repo->run('checkout', array('-B', $branch)); + public function getBranches($remote = null) { + // Query remotes + $result = $this->getRepository()->run('branch', $remote ? ['-r'] : []); + + // Filter output + $branches = []; + foreach(preg_split('/\R/u', $result) as $line) { + $line = trim($line); + // Skip empty lines, or anything with whitespace in it + if(empty($line) || preg_match('#\s#', $line)) { + continue; + } + // Check remote prefix + if($remote) { + $prefix = "{$remote}/"; + if(stripos($line, $prefix) === 0) { + $line = substr($line, strlen($prefix)); + } else { + // Skip if not a branch on this remote + continue; + } + } + + // Save branch + $branches[] = $line; + } + return $branches; } /** @@ -216,6 +250,7 @@ public function addTag($tag) * * @param string $remote * @param bool $tags Push tags? + * @throws Exception */ public function pushTo($remote = 'origin', $tags = false) { @@ -235,4 +270,102 @@ public function pushTo($remote = 'origin', $tags = false) // Push $repo->run('push', $args); } + + /** + * Fetch all upstream changes + * + * @param OutputInterface $output + * @param string $remote + */ + public function fetch(OutputInterface $output, $remote = 'origin') { + $this->getRepository($output) + ->run('fetch', array($remote)); + } + + /** + * Checkout given branch name. + * + * Note that this method respects ambiguous branch names (e.g. 3.1.0 branch which + * may have just been tagged as 3.1.0, and is about to get deleted). + * + * @param OutputInterface $output + * @param string $branch + * @param string $remote + */ + public function checkout(OutputInterface $output, $branch, $remote = 'origin') { + // Check if local branch exists + $localBranches = $this->getBranches(); + $remoteBranches = $this->getBranches($remote); + $repository = $this->getRepository($output); + + // Make sure branch exists somewhere + if(!in_array($branch, $localBranches) && !in_array($branch, $remoteBranches)) { + throw new InvalidArgumentException("Branch {$branch} is not a local or remote branch"); + } + + // Check if we need to switch branch + if($this->getBranch() !== $branch) { + // Find source for branch to checkout from (must disambiguate from tags) + if (!in_array($branch, $localBranches)) { + $sourceRef = "{$remote}/{$branch}"; + } else { + $sourceRef = "refs/heads/{$branch}"; + } + + // Checkout branch + $repository->run('checkout', [ + '-B', + $branch, + $sourceRef, + ]); + } + + // If branch is on live and local, we need to synchronise changes on local + // (but don't push!) + if(in_array($branch, $localBranches) && in_array($branch, $remoteBranches)) { + $repository->run('pull', [$remote, $branch]); + } + } + + /** + * Merge changes into this module from the given branch + * + * @param OutputInterface $output + * @param string $branch + * @return bool True if successful, false if merge conflict + */ + public function merge(OutputInterface $output, $branch) { + $repository = $this->getRepository($output); + + $current = $this->getBranch(); + $repository->run('merge', [ + $branch, + '-m', + "Merge {$branch} into {$current}" + ]); + } + + /** + * Get composer.json as array format + * + * @return array + * @throws Exception + */ + public function getComposerData() { + $path = $this->getDirectory() . '/composer.json'; + if(!file_exists($path)) { + throw new Exception("No composer.json found in module " . $this->getName()); + } + return json_decode(file_get_contents($path), true); + } + + /** + * Get composer name + * + * @return string + */ + public function getComposerName() { + $data = $this->getComposerData(); + return $data['name']; + } } diff --git a/src/Model/Project.php b/src/Model/Project.php index e06745d..6cffa87 100644 --- a/src/Model/Project.php +++ b/src/Model/Project.php @@ -32,7 +32,7 @@ public static function exists_in($directory) } /** - * Gets the list of modules in this installer + * Gets the list of self.version modules in this installer * * @param array $filter Optional list of modules to filter * @param bool $listIsExclusive Set to true if this list is exclusive @@ -40,6 +40,8 @@ public static function exists_in($directory) */ public function getModules($filter = array(), $listIsExclusive = false) { + $composer = $this->getComposerData(); + // Include self as head module $modules = array(); if (empty($filter) || in_array($this->getName(), $filter) != $listIsExclusive) { @@ -53,11 +55,26 @@ public function getModules($filter = array(), $listIsExclusive = false) continue; } - // Filter + // Skip if filtered $name = basename($dir); - if (empty($filter) || in_array($name, $filter) != $listIsExclusive) { - $modules[] = new Module($dir, $name, $this); + if (!empty($filter) && (in_array($name, $filter) == $listIsExclusive)) { + continue; } + $module = new Module($dir, $name, $this); + + // Filter by self.version module, + // but let whitelisted queries to override this + if(empty($filter) || $listIsExclusive) { + $composerName = $module->getComposerName(); + if (!isset($composer['require'][$composerName]) || + ($composer['require'][$composerName] !== 'self.version') + ) { + continue; + } + } + + // Save + $modules[] = $module; } return $modules; } diff --git a/src/Model/ReleaseVersion.php b/src/Model/ReleaseVersion.php index 904f0e7..84e0c7c 100644 --- a/src/Model/ReleaseVersion.php +++ b/src/Model/ReleaseVersion.php @@ -35,9 +35,24 @@ class ReleaseVersion */ protected $stabilityVersion; + /** + * Helper method to parse a version + * + * @param string $version + * @return bool|array Either false, or an array of parts + */ + public static function parse($version) { + $valid = preg_match('/^(?\d+)\.(?\d+)\.(?\d+)(\-(?rc|alpha|beta)(?\d+)?)?$/', $version, $matches); + if(!$valid) { + return false; + } + return $matches; + } + public function __construct($version) { - if (!preg_match('/^(?\d+)\.(?\d+)\.(?\d+)(\-(?rc|alpha|beta)(?\d+)?)?$/', $version, $matches)) { + $matches = static::parse($version); + if ($matches === false) { throw new InvalidArgumentException( "Invalid version $version. Expect full version (3.1.13) with optional rc|alpha|beta suffix" ); diff --git a/src/Steps/Branch/MergeBranch.php b/src/Steps/Branch/MergeBranch.php new file mode 100644 index 0000000..0950b6f --- /dev/null +++ b/src/Steps/Branch/MergeBranch.php @@ -0,0 +1,165 @@ +setFrom($from); + $this->setTo($to); + $this->setPush($push); + } + + public function getStepName() + { + return 'merge'; + } + + public function run(InputInterface $input, OutputInterface $output) + { + $this->log($output, "Merging from " . $this->getFrom() . " to " . $this->getTo() . ""); + if($this->getPush()) { + $this->log($output, "Successful merges will be pushed to origin"); + } + + $this->conflicts = []; + foreach($this->getModules() as $module) { + $this->mergeModule($output, $module); + } + + // Display output + if($this->conflicts) { + $this->log($output, "Merge conflicts exist which must be resolved manually:"); + foreach($this->conflicts as $module) { + /** @var Module $module */ + $this->log($output, "" . $module->getDirectory() . ""); + } + } else { + $this->log($output, "All modules were merged without any conflicts"); + } + } + + /** + * Merge the given branches on this module + * + * @param OutputInterface $output + * @param Module $module + */ + protected function mergeModule(OutputInterface $output, Module $module) { + $this->log($output, "Merging module " . $module->getComposerName() . ""); + + $module->fetch($output); + $module->checkout($output, $this->getFrom()); + $module->checkout($output, $this->getTo()); + + try { + $module->merge($output, $this->getFrom()); + $this->Log($output, "Merge successful!"); + + if($this->getPush()) { + $this->log($output, "Pushing upstream"); + $module->pushTo(); + } + + } catch(ProcessException $ex) { + // Module has conflicts; Please merge! + $this->log($output, "Merge conflict in module " . $module->getName() . ""); + $this->conflicts[] = $module; + } + } + + /** + * @return string + */ + public function getFrom() + { + return $this->from; + } + + /** + * @param string $from + * @return $this + */ + public function setFrom($from) + { + $this->from = $from; + return $this; + } + + /** + * @return string + */ + public function getTo() + { + return $this->to; + } + + /** + * @param string $to + * @return $this + */ + public function setTo($to) + { + $this->to = $to; + return $this; + } + + /** + * @return boolean + */ + public function getPush() + { + return $this->push; + } + + /** + * @param boolean $push + * @return $this + */ + public function setPush($push) + { + $this->push = $push; + return $this; + } + +} diff --git a/src/Steps/Release/CreateBranch.php b/src/Steps/Release/CreateBranch.php index 7af8aeb..864d76a 100644 --- a/src/Steps/Release/CreateBranch.php +++ b/src/Steps/Release/CreateBranch.php @@ -63,7 +63,7 @@ public function run(InputInterface $input, OutputInterface $output) $output, "Branching module ".$module->getName()." from {$thisBranch} to {$branch}" ); - $module->changeBranch($branch); + $module->checkout($output, $branch); } } $this->log($output, 'Branching complete'); From 2ccde128f7fc0b158bf4e9ea1477f83a8b89116b Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 4 Mar 2016 15:16:17 +1300 Subject: [PATCH 2/2] API Add --interactive mode to merge --- readme.md | 4 +- src/Commands/Branch/Merge.php | 13 +++++- src/Model/Module.php | 23 ++-------- src/Steps/Branch/MergeBranch.php | 79 ++++++++++++++++++++++++++++++-- src/Steps/Step.php | 34 ++++++++++++-- 5 files changed, 124 insertions(+), 29 deletions(-) diff --git a/readme.md b/readme.md index d7b802a..cb4d97d 100644 --- a/readme.md +++ b/readme.md @@ -104,7 +104,7 @@ to exclude. By default all modules are included, unless whitelisted or excluded. When a release is done, the laborious task of merging up all changes begins. This is where it can be handy to use the `branch:merge` command. This command has this syntax: -`branch:merge [, ..] [--push] [--exclude] [-vvv]` +`branch:merge [, ..] [--interactive] [--push] [--exclude] [-vvv]` This should be run in the project root, and will automatically merge each core module from the `` branch into the `` branch. If either branches haven't yet been @@ -121,3 +121,5 @@ trigger a push after the merge is complete. If a merge fails, or has unresolved conflicts, then a message will be displayed at the end of execution with the list of directories that should be manually resolved. Once resolved (and committed), just run the command again and it should continue. + +`--interactive` mode will pause before each commit, to allow you to review changes. diff --git a/src/Commands/Branch/Merge.php b/src/Commands/Branch/Merge.php index fc18e3c..2adf0b1 100644 --- a/src/Commands/Branch/Merge.php +++ b/src/Commands/Branch/Merge.php @@ -29,6 +29,7 @@ protected function configureOptions() { $this->addArgument('from', InputArgument::REQUIRED, 'Branch name to merge from'); $this->addArgument('to', InputArgument::REQUIRED, 'Branch name to merge to'); + $this->addOption('interactive', 'i', InputOption::VALUE_NONE, 'Use interactive mode'); parent::configureOptions(); // TODO: Change the autogenerated stub } @@ -41,13 +42,14 @@ protected function fire() $push = $this->getInputPush(); $from = $this->getInputFrom(); $to = $this->getInputTo(); + $interactive = $this->getInputInteractive(); // Bit of a sanity check on version if(!$this->canMerge($from, $to)) { throw new \InvalidArgumentException("{$to} seems like an older version that {$from}. Are you sure that's correct?"); } - $merge = new MergeBranch($this, $directory, $modules, $listIsExclusive, $from, $to, $push); + $merge = new MergeBranch($this, $directory, $modules, $listIsExclusive, $from, $to, $push, $interactive); $merge->run($this->input, $this->output); } @@ -102,4 +104,13 @@ protected function getInputTo() { return $this->input->getArgument('to'); } + + /** + * Check if running in interactive mode + * + * @return bool + */ + protected function getInputInteractive() { + return $this->input->getOption('interactive'); + } } diff --git a/src/Model/Module.php b/src/Model/Module.php index 8c130e5..be7d5bf 100644 --- a/src/Model/Module.php +++ b/src/Model/Module.php @@ -163,7 +163,10 @@ public function getLink() public function getRepository(OutputInterface $output = null) { $repo = new Repository($this->directory, array( - 'environment_variables' => array('HOME' => getenv('HOME')) + 'environment_variables' => array( + 'HOME' => getenv('HOME'), + 'SSH_AUTH_SOCK' => getenv('SSH_AUTH_SOCK') + ) )); // Include logger if requested if($output) { @@ -327,24 +330,6 @@ public function checkout(OutputInterface $output, $branch, $remote = 'origin') { } } - /** - * Merge changes into this module from the given branch - * - * @param OutputInterface $output - * @param string $branch - * @return bool True if successful, false if merge conflict - */ - public function merge(OutputInterface $output, $branch) { - $repository = $this->getRepository($output); - - $current = $this->getBranch(); - $repository->run('merge', [ - $branch, - '-m', - "Merge {$branch} into {$current}" - ]); - } - /** * Get composer.json as array format * diff --git a/src/Steps/Branch/MergeBranch.php b/src/Steps/Branch/MergeBranch.php index 0950b6f..8b18c1d 100644 --- a/src/Steps/Branch/MergeBranch.php +++ b/src/Steps/Branch/MergeBranch.php @@ -8,6 +8,8 @@ use SilverStripe\Cow\Steps\Release\ModuleStep; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\ConfirmationQuestion; /** * Class MergeBranch @@ -35,6 +37,11 @@ class MergeBranch extends ModuleStep */ protected $push = false; + /** + * @var bool + */ + protected $interactive = false; + /** * List of repos with conflicts * @@ -42,12 +49,13 @@ class MergeBranch extends ModuleStep */ protected $conflicts = []; - public function __construct(Command $command, $directory = '.', $modules = array(), $listIsExclusive = false, $from, $to, $push) + public function __construct(Command $command, $directory = '.', $modules = array(), $listIsExclusive = false, $from, $to, $push, $interactive) { parent::__construct($command, $directory, $modules, $listIsExclusive); $this->setFrom($from); $this->setTo($to); $this->setPush($push); + $this->setInteractive($interactive); } public function getStepName() @@ -64,7 +72,7 @@ public function run(InputInterface $input, OutputInterface $output) $this->conflicts = []; foreach($this->getModules() as $module) { - $this->mergeModule($output, $module); + $this->mergeModule($input, $output, $module); } // Display output @@ -82,10 +90,12 @@ public function run(InputInterface $input, OutputInterface $output) /** * Merge the given branches on this module * + * @param InputInterface $input * @param OutputInterface $output * @param Module $module + * @throws \Exception */ - protected function mergeModule(OutputInterface $output, Module $module) { + protected function mergeModule(InputInterface $input, OutputInterface $output, Module $module) { $this->log($output, "Merging module " . $module->getComposerName() . ""); $module->fetch($output); @@ -93,9 +103,52 @@ protected function mergeModule(OutputInterface $output, Module $module) { $module->checkout($output, $this->getTo()); try { - $module->merge($output, $this->getFrom()); + // Create merge + $repository = $module->getRepository($output); + $message = sprintf("Merge %s into %s", $this->getFrom(), $this->getTo()); + $result = $repository->run('merge', [ + $this->getFrom(), + '--no-commit', + '--no-ff', + '-m', + $message + ]); + + // Skip if there is nothing to merge + if(stripos($result, "Already up-to-date.") === 0) { + $this->log($output, "No changes to merge, skipping"); + return; + } + + // check interactive mode + if($this->getInteractive()) { + $helper = $this->getQuestionHelper(); + $this->log($output, "Changes pending review in " . $module->getDirectory() . ""); + $question = new ChoiceQuestion( + "Please review changes and confirm (defaults to continue): ", + array("continue", "skip", "abort"), + "continue" + ); + $answer = $helper->ask($input, $output, $question); + if($answer !== "continue") { + $this->log($output, "Reverting merge..."); + $repository->run('merge', ['--abort']); + } + + // Let's get out of here! + if($answer === 'abort') { + die(); + } + if($answer === 'skip') { + return; + } + } + + // Commit merge + $repository->run('commit', ['-m', $message]); $this->Log($output, "Merge successful!"); + // Do upstream push if($this->getPush()) { $this->log($output, "Pushing upstream"); $module->pushTo(); @@ -162,4 +215,22 @@ public function setPush($push) return $this; } + /** + * @return bool + */ + public function getInteractive() + { + return $this->interactive; + } + + /** + * @param bool $interactive + * @return $this + */ + public function setInteractive($interactive) + { + $this->interactive = $interactive; + return $this; + } + } diff --git a/src/Steps/Step.php b/src/Steps/Step.php index d51f9ba..3edf6e8 100644 --- a/src/Steps/Step.php +++ b/src/Steps/Step.php @@ -5,6 +5,7 @@ use Exception; use SilverStripe\Cow\Commands\Command; use Symfony\Component\Console\Helper\ProcessHelper; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; @@ -19,7 +20,7 @@ abstract class Step public function __construct(Command $command) { - $this->command = $command; + $this->setCommand($command); } abstract public function getStepName(); @@ -39,12 +40,36 @@ public function log(OutputInterface $output, $message, $format = '') } /** - * + * @return Command + */ + public function getCommand() + { + return $this->command; + } + + /** + * @param Command $command + * @return $this + */ + public function setCommand($command) + { + $this->command = $command; + return $this; + } + + /** * @return ProcessHelper */ protected function getProcessHelper() { - return $this->command->getHelper('process'); + return $this->getCommand()->getHelper('process'); + } + + /** + * @return QuestionHelper + */ + protected function getQuestionHelper() { + return $this->getCommand()->getHelper('question'); } /** @@ -56,7 +81,8 @@ protected function getProcessHelper() * @param string|array|Process $command An instance of Process or an array of arguments to escape and run or a command to run * @param string|null $error An error message that must be displayed if something went wrong * @param bool $exceptionOnError If an error occurs, this message is an exception rather than a notice - * @return string|bool Output, or false if error + * @return bool|string Output, or false if error + * @throws Exception */ protected function runCommand(OutputInterface $output, $command, $error = null, $exceptionOnError = true) {