diff --git a/src/lint/linter/ArcanistPEP8Linter.php b/src/lint/linter/ArcanistPEP8Linter.php index 80d7ac95..c12b50f2 100644 --- a/src/lint/linter/ArcanistPEP8Linter.php +++ b/src/lint/linter/ArcanistPEP8Linter.php @@ -1,119 +1,118 @@ getEngine()->getWorkingCopy(); $options = $working_copy->getConfig('lint.pep8.options'); if ($options === null) { $options = $this->getConfig('options'); } return $options; } public function getPEP8Path() { $working_copy = $this->getEngine()->getWorkingCopy(); $prefix = $working_copy->getConfig('lint.pep8.prefix'); $bin = $working_copy->getConfig('lint.pep8.bin'); if ($bin === null && $prefix === null) { $bin = csprintf('/usr/bin/env python2.6 %s', phutil_get_library_root('arcanist'). '/../externals/pep8/pep8.py'); - } - else { + } else { if ($bin === null) { $bin = 'pep8'; } if ($prefix !== null) { if (!Filesystem::pathExists($prefix.'/'.$bin)) { throw new ArcanistUsageException( "Unable to find PEP8 binary in a specified directory. Make sure ". "that 'lint.pep8.prefix' and 'lint.pep8.bin' keys are set ". "correctly. If you'd rather use a copy of PEP8 installed ". "globally, you can just remove these keys from your .arcconfig."); } $bin = csprintf("%s/%s", $prefix, $bin); return $bin; } // Look for globally installed PEP8 list($err) = exec_manual('which %s', $bin); if ($err) { throw new ArcanistUsageException( "PEP8 does not appear to be installed on this system. Install it ". "(e.g., with 'easy_install pep8') or configure ". "'lint.pep8.prefix' in your .arcconfig to point to the directory ". "where it resides."); } } return $bin; } public function lintPath($path) { $pep8_bin = $this->getPEP8Path(); $options = $this->getPEP8Options(); list($rc, $stdout) = exec_manual( "%C %C %s", $pep8_bin, $options, $this->getEngine()->getFilePathOnDisk($path)); $lines = explode("\n", $stdout); $messages = array(); foreach ($lines as $line) { $matches = null; if (!preg_match('/^(.*?):(\d+):(\d+): (\S+) (.*)$/', $line, $matches)) { continue; } foreach ($matches as $key => $match) { $matches[$key] = trim($match); } if (!$this->isMessageEnabled($matches[4])) { continue; } $message = new ArcanistLintMessage(); $message->setPath($path); $message->setLine($matches[2]); $message->setChar($matches[3]); $message->setCode($matches[4]); $message->setName('PEP8 '.$matches[4]); $message->setDescription($matches[5]); $message->setSeverity(ArcanistLintSeverity::SEVERITY_WARNING); $this->addLintMessage($message); } } } diff --git a/src/unit/engine/NoseTestEngine.php b/src/unit/engine/NoseTestEngine.php index fd508e10..352cd346 100644 --- a/src/unit/engine/NoseTestEngine.php +++ b/src/unit/engine/NoseTestEngine.php @@ -1,241 +1,240 @@ getPaths(); $affected_tests = array(); foreach ($paths as $path) { $absolute_path = Filesystem::resolvePath($path); if (is_dir($absolute_path)) { $absolute_test_path = Filesystem::resolvePath("tests/".$path); if (is_readable($absolute_test_path)) { $affected_tests[] = $absolute_test_path; } } if (is_readable($absolute_path)) { $filename = basename($path); $directory = dirname($path); // assumes directory layout: tests//test_.py $relative_test_path = "tests/".$directory."/test_".$filename; $absolute_test_path = Filesystem::resolvePath($relative_test_path); if (is_readable($absolute_test_path)) { $affected_tests[] = $absolute_test_path; } } } if (empty($affected_tests)) { return array(); } $futures = array(); $tmpfiles = array(); foreach ($affected_tests as $test_path) { $xunit_tmp = new TempFile(); $cover_tmp = new TempFile(); $future = $this->buildTestFuture($test_path, $xunit_tmp, $cover_tmp); $futures[$test_path] = $future; $tmpfiles[$test_path] = array( 'xunit' => $xunit_tmp, 'cover' => $cover_tmp, ); } $results = array(); foreach (Futures($futures)->limit(4) as $test_path => $future) { try { list($stdout, $stderr) = $future->resolvex(); } catch(CommandException $exc) { if ($exc->getError() > 1) { // 'nose' returns 1 when tests are failing/broken. throw $exc; } } $xunit_tmp = $tmpfiles[$test_path]['xunit']; $cover_tmp = $tmpfiles[$test_path]['cover']; $results[] = $this->parseTestResults($test_path, $xunit_tmp, $cover_tmp); } return array_mergev($results); } public function buildTestFuture($path, $xunit_tmp, $cover_tmp) { $cmd_line = csprintf("nosetests --with-xunit --xunit-file=%s", $xunit_tmp); if ($this->getEnableCoverage() !== false) { $cmd_line .= csprintf(" --with-coverage --cover-xml " . "--cover-xml-file=%s", $cover_tmp); } return new ExecFuture("%C %s", $cmd_line, $path); } public function parseTestResults($path, $xunit_tmp, $cover_tmp) { // xunit xsd: https://gist.github.com/959290 $xunit_dom = new DOMDocument(); $xunit_dom->loadXML(Filesystem::readFile($xunit_tmp)); // coverage is for all testcases in the executed $path $coverage = array(); if ($this->getEnableCoverage() !== false) { $coverage = $this->readCoverage($cover_tmp); } $results = array(); $testcases = $xunit_dom->getElementsByTagName("testcase"); foreach ($testcases as $testcase) { $classname = $testcase->getAttribute("classname"); $name = $testcase->getAttribute("name"); $time = $testcase->getAttribute("time"); $status = ArcanistUnitTestResult::RESULT_PASS; $user_data = ""; // A skipped test is a test which was ignored using framework // mechanizms (e.g. @skip decorator) $skipped = $testcase->getElementsByTagName("skipped"); if ($skipped->length > 0) { $status = ArcanistUnitTestResult::RESULT_SKIP; $messages = array(); for ($ii = 0; $ii < $skipped->length; $ii++) { $messages[] = trim($skipped->item($ii)->nodeValue, " \n"); } $user_data .= implode("\n", $messages); } // Failure is a test which the code has explicitly failed by using // the mechanizms for that purpose. e.g., via an assertEquals $failures = $testcase->getElementsByTagName("failure"); if ($failures->length > 0) { $status = ArcanistUnitTestResult::RESULT_FAIL; $messages = array(); for ($ii = 0; $ii < $failures->length; $ii++) { $messages[] = trim($failures->item($ii)->nodeValue, " \n"); } $user_data .= implode("\n", $messages)."\n"; } // An errored test is one that had an unanticipated problem. e.g., an // unchecked throwable, or a problem with an implementation of the // test. $errors = $testcase->getElementsByTagName("error"); if ($errors->length > 0) { $status = ArcanistUnitTestResult::RESULT_BROKEN; $messages = array(); for ($ii = 0; $ii < $errors->length; $ii++) { $messages[] = trim($errors->item($ii)->nodeValue, " \n"); } $user_data .= implode("\n", $messages)."\n"; } $result = new ArcanistUnitTestResult(); $result->setName($classname.".".$name); $result->setResult($status); $result->setDuration($time); $result->setCoverage($coverage); $result->setUserData($user_data); $results[] = $result; } return $results; } public function readCoverage($path) { $coverage_dom = new DOMDocument(); $coverage_dom->loadXML(Filesystem::readFile($path)); $reports = array(); $classes = $coverage_dom->getElementsByTagName("class"); foreach ($classes as $class) { // filename is actually python module path with ".py" at the end, // e.g.: tornado.web.py $relative_path = explode(".", $class->getAttribute("filename")); array_pop($relative_path); $relative_path = implode("/", $relative_path); // first we check if the path is a directory (a Python package), if it is // set relative and absolute paths to have __init__.py at the end. $absolute_path = Filesystem::resolvePath($relative_path); if (is_dir($absolute_path)) { $relative_path .= "/__init__.py"; $absolute_path .= "/__init__.py"; } // then we check if the path with ".py" at the end is file (a Python // submodule), if it is - set relative and absolute paths to have // ".py" at the end. if (is_file($absolute_path.".py")) { $relative_path .= ".py"; $absolute_path .= ".py"; } if (!file_exists($absolute_path)) { continue; } // get total line count in file $line_count = count(file($absolute_path)); $coverage = ""; $start_line = 1; $lines = $class->getElementsByTagName("line"); for ($ii = 0; $ii < $lines->length; $ii++) { $line = $lines->item($ii); $next_line = intval($line->getAttribute("number")); for ($start_line; $start_line < $next_line; $start_line++) { $coverage .= "N"; } if (intval($line->getAttribute("hits")) == 0) { $coverage .= "U"; - } - else if (intval($line->getAttribute("hits")) > 0) { + } else if (intval($line->getAttribute("hits")) > 0) { $coverage .= "C"; } $start_line++; } if ($start_line < $line_count) { foreach (range($start_line, $line_count) as $line_num) { $coverage .= "N"; } } $reports[$relative_path] = $coverage; } return $reports; } } diff --git a/src/unit/engine/PhpunitTestEngine.php b/src/unit/engine/PhpunitTestEngine.php index 7dd5b936..d2f02a8b 100644 --- a/src/unit/engine/PhpunitTestEngine.php +++ b/src/unit/engine/PhpunitTestEngine.php @@ -1,410 +1,409 @@ projectRoot = $this->getWorkingCopy()->getProjectRoot(); $this->affectedTests = array(); foreach ($this->getPaths() as $path) { $path = Filesystem::resolvePath($path, $this->projectRoot); // TODO: add support for directories // Users can call phpunit on the directory themselves if (is_dir($path)) { continue; } // Not sure if it would make sense to go further if // it is not a .php file if (substr($path, -4) != '.php') { continue; } if (substr($path, -8) == 'Test.php') { // Looks like a valid test file name. $this->affectedTests[$path] = $path; continue; } if ($test = $this->findTestFile($path)) { $this->affectedTests[$path] = $test; } } if (empty($this->affectedTests)) { throw new ArcanistNoEffectException('No tests to run.'); } $this->prepareConfigFile(); $futures = array(); $tmpfiles = array(); foreach ($this->affectedTests as $class_path => $test_path) { $json_tmp = new TempFile(); $clover_tmp = null; $clover = null; if ($this->getEnableCoverage() !== false) { $clover_tmp = new TempFile(); $clover = csprintf('--coverage-clover %s', $clover_tmp); } $config = $this->configFile ? csprintf('-c %s', $this->configFile) : null; $futures[$test_path] = new ExecFuture('phpunit %C --log-json %s %C %s', $config, $json_tmp, $clover, $test_path); $tmpfiles[$test_path] = array( 'json' => $json_tmp, 'clover' => $clover_tmp, ); } $results = array(); foreach (Futures($futures)->limit(4) as $test => $future) { list($err, $stdout, $stderr) = $future->resolve(); $results[] = $this->parseTestResults($test_path, $tmpfiles[$test]['json'], $tmpfiles[$test]['clover']); } return array_mergev($results); } /** * We need this non-sense to make json generated by phpunit * valid. * * @param string $json_tmp Path to JSON report * * @return array JSON decoded array */ private function getJsonReport($json_tmp) { $json = Filesystem::readFile($json_tmp); if (empty($json)) { throw new Exception('JSON report file is empty, ' . 'it probably means that phpunit failed to run tests. ' . 'Try running arc unit with --trace option and then run ' . 'generated phpunit command yourself, you might get the ' . 'answer.' ); } $json = preg_replace('/}{\s*"/', '},{"', $json); $json = '[' . $json . ']'; $json = json_decode($json); if (!is_array($json)) { throw new Exception('JSON could not be decoded'); } return $json; } /** * Parse test results from phpunit json report * * @param string $path Path to test * @param string $json_path Path to phpunit json report * @param string $clover_tmp Path to phpunit clover report * * @return array */ private function parseTestResults($path, $json_tmp, $clover_tmp) { $test_results = Filesystem::readFile($json_tmp); $report = $this->getJsonReport($json_tmp); // coverage is for all testcases in the executed $path $coverage = array(); if ($this->getEnableCoverage() !== false) { $coverage = $this->readCoverage($clover_tmp); } $results = array(); foreach ($report as $event) { if ('test' != $event->event) { continue; } $status = ArcanistUnitTestResult::RESULT_PASS; $user_data = ''; if ('fail' == $event->status) { $status = ArcanistUnitTestResult::RESULT_FAIL; $user_data .= $event->message . "\n"; foreach ($event->trace as $trace) { $user_data .= sprintf("\n%s:%s", $trace->file, $trace->line); } } else if ('error' == $event->status) { if ('Skipped Test' == $event->message) { $status = ArcanistUnitTestResult::RESULT_SKIP; $user_data .= $event->message; } else if ('Incomplete Test' == $event->message) { $status = ArcanistUnitTestResult::RESULT_SKIP; $user_data .= $event->message; } else { $status = ArcanistUnitTestResult::RESULT_BROKEN; $user_data .= $event->message; foreach ($event->trace as $trace) { $user_data .= sprintf("\n%s:%s", $trace->file, $trace->line); } } } $name = preg_replace('/ \(.*\)/', '', $event->test); $result = new ArcanistUnitTestResult(); $result->setName($name); $result->setResult($status); $result->setDuration($event->time); $result->setCoverage($coverage); $result->setUserData($user_data); $results[] = $result; } return $results; } /** * Red the coverage from phpunit generated clover report * * @param string $path Path to report * * @return array */ private function readCoverage($path) { $test_results = Filesystem::readFile($path); if (empty($test_results)) { throw new Exception('Clover coverage XML report file is empty, ' . 'it probably means that phpunit failed to run tests. ' . 'Try running arc unit with --trace option and then run ' . 'generated phpunit command yourself, you might get the ' . 'answer.' ); } $coverage_dom = new DOMDocument(); $coverage_dom->loadXML($test_results); $reports = array(); $files = $coverage_dom->getElementsByTagName('file'); foreach ($files as $file) { $class_path = $file->getAttribute('name'); if (empty($this->affectedTests[$class_path])) { continue; } $test_path = $this->affectedTests[$file->getAttribute('name')]; // get total line count in file $line_count = count(file($class_path)); $coverage = ''; $start_line = 1; $lines = $file->getElementsByTagName('line'); for ($ii = 0; $ii < $lines->length; $ii++) { $line = $lines->item($ii); for (; $start_line < $line->getAttribute('num'); $start_line++) { $coverage .= 'N'; } if ($line->getAttribute('type') != 'stmt') { $coverage .= 'N'; } else { if ((int) $line->getAttribute('count') == 0) { $coverage .= 'U'; - } - else if ((int) $line->getAttribute('count') > 0) { + } else if ((int) $line->getAttribute('count') > 0) { $coverage .= 'C'; } } $start_line++; } for (; $start_line <= $line_count; $start_line++) { $coverage .= 'N'; } $len = strlen($this->projectRoot . DIRECTORY_SEPARATOR); $class_path = substr($class_path, $len); $reports[$class_path] = $coverage; } return $reports; } /** * Search for test cases for a given file in a large number of "reasonable" * locations. See @{method:getSearchLocationsForTests} for specifics. * * TODO: Add support for finding tests in testsuite folders from * phpunit.xml configuration. * * @param string PHP file to locate test cases for. * @return string|null Path to test cases, or null. */ private function findTestFile($path) { $root = $this->projectRoot; $path = Filesystem::resolvePath($path, $root); $file = basename($path); $possible_files = array( $file, substr($file, 0, -4).'Test.php', ); $search = self::getSearchLocationsForTests($path); foreach ($search as $search_path) { foreach ($possible_files as $possible_file) { $full_path = $search_path.$possible_file; if (!Filesystem::pathExists($full_path)) { // If the file doesn't exist, it's clearly a miss. continue; } if (!Filesystem::isDescendant($full_path, $root)) { // Don't look above the project root. continue; } if (Filesystem::resolvePath($full_path) == $path) { // Don't return the original file. continue; } return $full_path; } } return null; } /** * Get places to look for PHP Unit tests that cover a given file. For some * file "/a/b/c/X.php", we look in the same directory: * * /a/b/c/ * * We then look in all parent directories for a directory named "tests/" * (or "Tests/"): * * /a/b/c/tests/ * /a/b/tests/ * /a/tests/ * /tests/ * * We also try to replace each directory component with "tests/": * * /a/b/tests/ * /a/tests/c/ * /tests/b/c/ * * We also try to add "tests/" at each directory level: * * /a/b/c/tests/ * /a/b/tests/c/ * /a/tests/b/c/ * /tests/a/b/c/ * * This finds tests with a layout like: * * docs/ * src/ * tests/ * * ...or similar. This list will be further pruned by the caller; it is * intentionally filesystem-agnostic to be unit testable. * * @param string PHP file to locate test cases for. * @return list List of directories to search for tests in. */ public static function getSearchLocationsForTests($path) { $file = basename($path); $dir = dirname($path); $test_dir_names = array('tests', 'Tests'); $try_directories = array(); // Try in the current directory. $try_directories[] = array($dir); // Try in a tests/ directory anywhere in the ancestry. foreach (Filesystem::walkToRoot($dir) as $parent_dir) { if ($parent_dir == '/') { // We'll restore this later. $parent_dir = ''; } foreach ($test_dir_names as $test_dir_name) { $try_directories[] = array($parent_dir, $test_dir_name); } } // Try replacing each directory component with 'tests/'. $parts = trim($dir, DIRECTORY_SEPARATOR); $parts = explode(DIRECTORY_SEPARATOR, $parts); foreach (array_reverse(array_keys($parts)) as $key) { foreach ($test_dir_names as $test_dir_name) { $try = $parts; $try[$key] = $test_dir_name; array_unshift($try, ''); $try_directories[] = $try; } } // Try adding 'tests/' at each level. foreach (array_reverse(array_keys($parts)) as $key) { foreach ($test_dir_names as $test_dir_name) { $try = $parts; $try[$key] = $test_dir_name.DIRECTORY_SEPARATOR.$try[$key]; array_unshift($try, ''); $try_directories[] = $try; } } $results = array(); foreach ($try_directories as $parts) { $results[implode(DIRECTORY_SEPARATOR, $parts).DIRECTORY_SEPARATOR] = true; } return array_keys($results); } /** * Tries to find and update phpunit configuration file * based on phpunit_config option in .arcconfig */ private function prepareConfigFile() { $project_root = $this->projectRoot . DIRECTORY_SEPARATOR; if ($config = $this->getWorkingCopy()->getConfig('phpunit_config')) { if (Filesystem::pathExists($project_root . $config)) { $this->configFile = $project_root . $config; } else { throw new Exception('PHPUnit configuration file was not ' . 'found in ' . $project_root . $config); } } } } diff --git a/src/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php index 9525079a..b9903c8a 100644 --- a/src/workflow/ArcanistLandWorkflow.php +++ b/src/workflow/ArcanistLandWorkflow.php @@ -1,844 +1,841 @@ array( 'param' => 'master', 'help' => "Land feature branch onto a branch other than the default ". "('master' in git, 'default' in hg). You can change the ". "default by setting 'arc.land.onto.default' with ". "`arc set-config` or for the entire project in .arcconfig.", ), 'hold' => array( 'help' => "Prepare the change to be pushed, but do not actually ". "push it.", ), 'keep-branch' => array( 'help' => "Keep the feature branch after pushing changes to the ". "remote (by default, it is deleted).", ), 'remote' => array( 'param' => 'origin', 'help' => "Push to a remote other than the default ('origin' in git).", ), 'merge' => array( 'help' => 'Perform a --no-ff merge, not a --squash merge. If the '. 'project is marked as having an immutable history, this is '. 'the default behavior.', 'supports' => array( 'git', ), 'nosupport' => array( 'hg' => 'Use the --squash strategy when landing in mercurial.', ), ), 'squash' => array( 'help' => 'Perform a --squash merge, not a --no-ff merge. If the '. 'project is marked as having a mutable history, this is '. 'the default behavior.', 'conflicts' => array( 'merge' => '--merge and --squash are conflicting merge strategies.', ), ), 'delete-remote' => array( 'help' => 'Delete the feature branch in the remote after '. 'landing it.', 'conflicts' => array( 'keep-branch' => true, ), ), 'update-with-rebase' => array( 'help' => 'When updating the feature branch, use rebase intead of '. 'merge. This might make things work better in some cases.', 'conflicts' => array( 'merge' => 'The --merge strategy does not update the feature branch.', ), ), 'revision' => array( 'param' => 'id', 'help' => 'Use the message from a specific revision, rather than '. 'inferring the revision based on branch content.', ), '*' => 'branch', ); } public function run() { $this->readArguments(); $this->validate(); try { $this->pullFromRemote(); } catch (Exception $ex) { $this->restoreBranch(); throw $ex; } $this->checkoutBranch(); $this->findRevision(); if ($this->useSquash) { $this->rebase(); $this->squash(); } else { $this->merge(); } $this->push(); if (!$this->keepBranch) { $this->cleanupBranch(); } // If we were on some branch A and the user ran "arc land B", // switch back to A. if ($this->oldBranch != $this->branch && $this->oldBranch != $this->onto) { $this->restoreBranch(); } echo "Done.\n"; return 0; } private function readArguments() { $repository_api = $this->getRepositoryAPI(); $this->isGit = $repository_api instanceof ArcanistGitAPI; $this->isHg = $repository_api instanceof ArcanistMercurialAPI; if (!$this->isGit && !$this->isHg) { throw new ArcanistUsageException( "'arc land' only supports git and mercurial."); } if ($this->isGit) { $repository = $this->loadProjectRepository(); $this->isGitSvn = (idx($repository, 'vcs') == 'svn'); } $branch = $this->getArgument('branch'); if (empty($branch)) { $branch = $this->getBranchOrBookmark(); if ($branch) { echo "Landing current branch '{$branch}'.\n"; $branch = array($branch); } } if (count($branch) !== 1) { throw new ArcanistUsageException( "Specify exactly one branch to land changes from."); } $this->branch = head($branch); $this->keepBranch = $this->getArgument('keep-branch'); $this->shouldUpdateWithRebase = $this->getArgument('update-with-rebase'); $onto_default = $this->isGit ? 'master' : 'default'; $onto_default = nonempty( $this->getWorkingCopy()->getConfigFromAnySource('arc.land.onto.default'), $onto_default); $this->onto = $this->getArgument('onto', $onto_default); $remote_default = $this->isGit ? 'origin' : ''; $this->remote = $this->getArgument('remote', $remote_default); if ($this->getArgument('merge')) { $this->useSquash = false; } else if ($this->getArgument('squash')) { $this->useSquash = true; } else { $this->useSquash = !$this->isHistoryImmutable(); } $this->ontoRemoteBranch = $this->onto; if ($this->isGitSvn) { $this->ontoRemoteBranch = 'trunk'; } else if ($this->isGit) { $this->ontoRemoteBranch = $this->remote.'/'.$this->onto; } $this->oldBranch = $this->getBranchOrBookmark(); } private function validate() { $repository_api = $this->getRepositoryAPI(); if ($this->onto == $this->branch) { $message = "You can not land a branch onto itself -- you are trying to land ". "'{$this->branch}' onto '{$this->onto}'. For more information on ". "how to push changes, see 'Pushing and Closing Revisions' in ". "'Arcanist User Guide: arc diff' in the documentation."; if (!$this->isHistoryImmutable()) { $message .= " You may be able to 'arc amend' instead."; } throw new ArcanistUsageException($message); } if ($this->isHg) { if ($this->useSquash) { list ($err) = $repository_api->execManualLocal("rebase --help"); if ($err) { throw new ArcanistUsageException( "You must enable the rebase extension to use ". "the --squash strategy."); } } if ($repository_api->isBookmark($this->branch) && !$repository_api->isBookmark($this->onto)) { throw new ArcanistUsageException( "Source {$this->branch} is a bookmark but destination ". "{$this->onto} is not a bookmark. When landing a bookmark, ". "the destination must also be a bookmark. Use --onto to specify ". "a bookmark, or set arc.land.onto.default in .arcconfig."); } if ($repository_api->isBranch($this->branch) && !$repository_api->isBranch($this->onto)) { throw new ArcanistUsageException( "Source {$this->branch} is a branch but destination {$this->onto} ". "is not a branch. When landing a branch, the destination must also ". "be a branch. Use --onto to specify a branch, or set ". "arc.land.onto.default in .arcconfig."); } } if ($this->isGit) { list($err) = $repository_api->execManualLocal( 'rev-parse --verify %s', $this->branch); if ($err) { throw new ArcanistUsageException( "Branch '{$this->branch}' does not exist."); } } $this->requireCleanWorkingCopy(); } private function checkoutBranch() { $repository_api = $this->getRepositoryAPI(); $repository_api->execxLocal( 'checkout %s', $this->branch); echo phutil_console_format( "Switched to branch **%s**. Identifying and merging...\n", $this->branch); } private function findRevision() { $repository_api = $this->getRepositoryAPI(); $this->parseBaseCommitArgument(array($this->ontoRemoteBranch)); $revision_id = $this->getArgument('revision'); if ($revision_id) { $revision_id = $this->normalizeRevisionID($revision_id); $revisions = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (!$revisions) { throw new ArcanistUsageException("No such revision 'D{$revision_id}'!"); } } else { $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), )); } if (!count($revisions)) { throw new ArcanistUsageException( "arc can not identify which revision exists on branch ". "'{$this->branch}'. Update the revision with recent changes ". "to synchronize the branch name and hashes, or use 'arc amend' ". "to amend the commit message at HEAD, or use '--revision ' ". "to select a revision explicitly."); } else if (count($revisions) > 1) { $message = "There are multiple revisions on feature branch '{$this->branch}' ". "which are not present on '{$this->onto}':\n\n". $this->renderRevisionList($revisions)."\n". "Separate these revisions onto different branches, or use ". "'--revision ' to select one."; throw new ArcanistUsageException($message); } $this->revision = head($revisions); $rev_status = $this->revision['status']; $rev_id = $this->revision['id']; $rev_title = $this->revision['title']; if ($rev_status != ArcanistDifferentialRevisionStatus::ACCEPTED) { $ok = phutil_console_confirm( "Revision 'D{$rev_id}: {$rev_title}' has not been ". "accepted. Continue anyway?"); if (!$ok) { throw new ArcanistUserAbortException(); } } $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $rev_id, )); $this->messageFile = new TempFile(); Filesystem::writeFile($this->messageFile, $message); echo "Landing revision 'D{$rev_id}: ". "{$rev_title}'...\n"; } private function pullFromRemote() { $repository_api = $this->getRepositoryAPI(); $repository_api->execxLocal('checkout %s', $this->onto); echo phutil_console_format( "Switched to branch **%s**. Updating branch...\n", $this->onto); $local_ahead_of_remote = false; if ($this->isGit) { $repository_api->execxLocal('pull --ff-only'); if (!$this->isGitSvn) { list($out) = $repository_api->execxLocal( 'log %s..%s', $this->ontoRemoteBranch, $this->onto); if (strlen(trim($out))) { $local_ahead_of_remote = true; } } } else if ($this->isHg) { // execManual instead of execx because outgoing returns // code 1 when there is nothing outgoing list($err, $out) = $repository_api->execManualLocal( 'outgoing -r %s', $this->onto); // $err === 0 means something is outgoing if ($err === 0) { $local_ahead_of_remote = true; - } - else { + } else { try { $repository_api->execxLocal('pull -u'); } catch (CommandException $ex) { $err = $ex->getError(); $stdout = $ex->getStdOut(); // Copied from: PhabricatorRepositoryPullLocalDaemon.php // NOTE: Between versions 2.1 and 2.1.1, Mercurial changed the // behavior of "hg pull" to return 1 in case of a successful pull // with no changes. This behavior has been reverted, but users who // updated between Feb 1, 2012 and Mar 1, 2012 will have the // erroring version. Do a dumb test against stdout to check for this // possibility. // See: https://github.com/facebook/phabricator/issues/101/ // NOTE: Mercurial has translated versions, which translate this error // string. In a translated version, the string will be something else, // like "aucun changement trouve". There didn't seem to be an easy way // to handle this (there are hard ways but this is not a common // problem and only creates log spam, not application failures). // Assume English. // TODO: Remove this once we're far enough in the future that // deployment of 2.1 is exceedingly rare? if ($err == 1 && preg_match('/no changes found/', $stdout)) { return; } else { throw $ex; } } } } if ($local_ahead_of_remote) { throw new ArcanistUsageException( "Local branch '{$this->onto}' is ahead of remote branch ". "'{$this->ontoRemoteBranch}', so landing a feature branch ". "would push additional changes. Push or reset the changes ". "in '{$this->onto}' before running 'arc land'."); } } private function rebase() { $repository_api = $this->getRepositoryAPI(); chdir($repository_api->getPath()); if ($this->isGit) { if ($this->shouldUpdateWithRebase) { $err = phutil_passthru('git rebase %s', $this->onto); if ($err) { throw new ArcanistUsageException( "'git rebase {$this->onto}' failed. ". "You can abort with 'git rebase --abort', ". "or resolve conflicts and use 'git rebase ". "--continue' to continue forward. After resolving the rebase, ". "run 'arc land' again."); } } else { $err = phutil_passthru( 'git merge %s -m %s', $this->onto, "Automatic merge by 'arc land'"); if ($err) { throw new ArcanistUsageException( "'git merge {$this->onto}' failed. ". "To continue: resolve the conflicts, commit the changes, then run ". "'arc land' again. To abort: run 'git merge --abort'."); } } } else if ($this->isHg) { // keep branch here so later we can decide whether to remove it $err = $repository_api->execPassthru( 'rebase -d %s --keepbranches', $this->onto); if ($err) { throw new ArcanistUsageException( "'hg rebase {$this->onto}' failed. ". "You can abort with 'hg rebase --abort', ". "or resolve conflicts and use 'hg rebase ". "--continue' to continue forward. After resolving the rebase, ". "run 'arc land' again."); } } $repository_api->reloadWorkingCopy(); } private function squash() { $repository_api = $this->getRepositoryAPI(); $repository_api->execxLocal('checkout %s', $this->onto); if ($this->isGit) { $repository_api->execxLocal( 'merge --squash --ff-only %s', $this->branch); - } - else if ($this->isHg) { + } else if ($this->isHg) { // The hg code is a little more complex than git's because we // need to handle the case where the landing branch has child branches: // -a--------b master // \ // w--x mybranch // \--y subbranch1 // \--z subbranch2 // // arc land --branch mybranch --onto master : // -a--b--wx master // \--y subbranch1 // \--z subbranch2 $branch_rev_id = $repository_api->getCanonicalRevisionName($this->branch); // At this point $this->onto has been pulled from remote and // $this->branch has been rebased on top of onto(by the rebase() // function). So we're guaranteed to have onto as an ancestor of branch // when we use first((onto::branch)-onto) below. $branch_root = $repository_api->getCanonicalRevisionName( sprintf("first((%s::%s)-%s)", $this->onto, $this->branch, $this->onto)); $branch_range = sprintf( "(%s::%s)", $branch_root, $this->branch); if (!$this->keepBranch) { $this->handleAlternateBranches($branch_root, $branch_range); } // Collapse just the landing branch onto master. // Leave its children on the original branch. $repository_api->execxLocal( 'rebase --collapse --keep --logfile %s -r %s -d %s', $this->messageFile, $branch_range, $this->onto); if ($repository_api->isBookmark($this->branch)) { // a bug in mercurial means bookmarks end up on the revision prior // to the collapse when using --collapse with --keep, // so we manually move them to the correct spots // see: http://bz.selenic.com/show_bug.cgi?id=3716 $repository_api->execxLocal( 'bookmark -f %s', $this->onto); $repository_api->execxLocal( 'bookmark -f %s -r %s', $this->branch, $branch_rev_id); } // check if the branch had children list($output) = $repository_api->execxLocal( "log -r %s --template '{node}\\n'", sprintf("children(%s)", $this->branch)); $child_branch_roots = phutil_split_lines($output, false); $child_branch_roots = array_filter($child_branch_roots); if ($child_branch_roots) { // move the branch's children onto the collapsed commit foreach ($child_branch_roots as $child_root) { $repository_api->execxLocal( 'rebase -d %s -s %s --keep --keepbranches', $this->onto, $child_root); } } // delete the old branch if necessary if (!$this->keepBranch) { $repository_api->execxLocal( 'strip -r %s', $branch_root); if ($repository_api->isBookmark($this->branch)) { $repository_api->execxLocal( 'bookmark -d %s', $this->branch); } } // All the rebases may have moved us to another branch // so we move back. $repository_api->execxLocal('checkout %s', $this->onto); } } /** * Detect alternate branches and prompt the user for how to handle * them. An alternate branch is a branch that forks from the landing * branch prior to the landing branch tip. * * In a situation like this: * -a--------b master * \ * w--x landingbranch * \ \-- g subbranch * \--y altbranch1 * \--z altbranch2 * * y and z are alternate branches and will get deleted by the squash, * so we need to detect them and ask the user what they want to do. * * @param string The revision id of the landing branch's root commit. * @param string The revset specifying all the commits in the landing branch. * @return void */ private function handleAlternateBranches($branch_root, $branch_range) { $repository_api = $this->getRepositoryAPI(); // Using the tree in the doccomment, the revset below resolves as follows: // 1. roots(descendants(w) - descendants(x) - (w::x)) // 2. roots({x,g,y,z} - {g} - {w,x}) // 3. roots({y,z}) // 4. {y,z} $alt_branch_revset = sprintf( 'roots(descendants(%s)-descendants(%s)-%s)', $branch_root, $this->branch, $branch_range); list($alt_branches) = $repository_api->execxLocal( "log --template '{node}\n' -r %s", $alt_branch_revset); $alt_branches = phutil_split_lines($alt_branches, false); $alt_branches = array_filter($alt_branches); $alt_count = count($alt_branches); if ($alt_count > 0) { $input = phutil_console_prompt( "Branch {$this->branch} has {$alt_count} branch(s) forking off of it ". "that would be deleted during a squash. Would you like to keep a ". "non-squashed copy, rebase them on top of {$this->branch}, or abort ". "and deal with them yourself? (k)eep, (r)ebase, (a)bort:"); if ($input == 'k' || $input == 'keep') { $this->keepBranch = true; } else if ($input == 'r' || $input == 'rebase') { foreach ($alt_branches as $alt_branch) { $repository_api->execxLocal( 'rebase --keep --keepbranches -d %s -s %s', $this->branch, $alt_branch); } } else if ($input == 'a' || $input == 'abort') { $branch_string = implode("\n", $alt_branches); echo "\nRemove the branches starting at these revision and ". "run arc land again:\n{$branch_string}\n\n"; throw new ArcanistUserAbortException(); } else { throw new ArcanistUsageException("Invalid choice. Aborting arc land."); } } } private function merge() { $repository_api = $this->getRepositoryAPI(); // In immutable histories, do a --no-ff merge to force a merge commit with // the right message. $repository_api->execxLocal('checkout %s', $this->onto); chdir($repository_api->getPath()); if ($this->isGit) { $err = phutil_passthru( 'git merge --no-ff --no-commit %s', $this->branch); if ($err) { throw new ArcanistUsageException( "'git merge' failed. Your working copy has been left in a partially ". "merged state. You can: abort with 'git merge --abort'; or follow ". "the instructions to complete the merge."); } - } - else if ($this->isHg) { + } else if ($this->isHg) { // HG arc land currently doesn't support --merge. // When merging a bookmark branch to a master branch that // hasn't changed since the fork, mercurial fails to merge. // Instead of only working in some cases, we just disable --merge // until there is a demand for it. // The user should never reach this line, since --merge is // forbidden at the command line argument level. throw new ArcanistUsageException( "--merge is not currently supported for hg repos."); } } private function push() { $repository_api = $this->getRepositoryAPI(); if ($this->isGit) { $repository_api->execxLocal( 'commit -F %s', $this->messageFile); } else if ($this->isHg) { // hg rebase produces a commit earlier as part of rebase if (!$this->useSquash) { $repository_api->execxLocal( 'commit --logfile %s', $this->messageFile); } } if ($this->getArgument('hold')) { echo phutil_console_format( "Holding change in **%s**: it has NOT been pushed yet.\n", $this->onto); } else { echo "Pushing change...\n\n"; chdir($repository_api->getPath()); if ($this->isGitSvn) { $err = phutil_passthru('git svn dcommit'); $cmd = "git svn dcommit"; } else if ($this->isGit) { $err = phutil_passthru( 'git push %s %s', $this->remote, $this->onto); $cmd = "git push"; } else if ($this->isHg) { $err = $repository_api->execPassthru( 'push --new-branch -r %s %s', $this->onto, $this->remote); $cmd = "hg push"; } if ($err) { echo phutil_console_format("** PUSH FAILED! **\n"); throw new ArcanistUsageException( "'{$cmd}' failed! Fix the error and push this change manually."); } $mark_workflow = $this->buildChildWorkflow( 'close-revision', array( '--finalize', '--quiet', $this->revision['id'], )); $mark_workflow->run(); echo "\n"; } } private function cleanupBranch() { $repository_api = $this->getRepositoryAPI(); echo "Cleaning up feature branch...\n"; if ($this->isGit) { list($ref) = $repository_api->execxLocal( 'rev-parse --verify %s', $this->branch); $ref = trim($ref); $recovery_command = csprintf( 'git checkout -b %s %s', $this->branch, $ref); echo "(Use `{$recovery_command}` if you want it back.)\n"; $repository_api->execxLocal( 'branch -D %s', $this->branch); } // hg branches/bookmarks were closed earlier if ($this->getArgument('delete-remote')) { if ($this->isGit) { list($err, $ref) = $repository_api->execManualLocal( 'rev-parse --verify %s/%s', $this->remote, $this->branch); if ($err) { echo "No remote feature branch to clean up.\n"; } else { // NOTE: In Git, you delete a remote branch by pushing it with a // colon in front of its name: // // git push : echo "Cleaning up remote feature branch...\n"; $repository_api->execxLocal( 'push %s :%s', $this->remote, $this->branch); } } else if ($this->isHg) { // named branches were closed as part of the earlier commit // so only worry about bookmarks if ($repository_api->isBookmark($this->branch)) { $repository_api->execxLocal( 'push -B %s %s', $this->branch, $this->remote); } } } } protected function getSupportedRevisionControlSystems() { return array('git', 'hg'); } private function getBranchOrBookmark() { $repository_api = $this->getRepositoryAPI(); if ($this->isGit) { $branch = $repository_api->getBranchName(); } else if ($this->isHg) { $branch = $repository_api->getActiveBookmark(); if (!$branch) { $branch = $repository_api->getBranchName(); } } return $branch; } /** * Restore the original branch, e.g. after a successful land or a failed * pull. */ private function restoreBranch() { $repository_api = $this->getRepositoryAPI(); $repository_api->execxLocal( 'checkout %s', $this->oldBranch); echo phutil_console_format( "Switched back to branch **%s**.\n", $this->oldBranch); } }