diff --git a/src/branch/BranchInfo.php b/src/branch/BranchInfo.php index 38881c3c..39d14d64 100644 --- a/src/branch/BranchInfo.php +++ b/src/branch/BranchInfo.php @@ -1,174 +1,174 @@ getAllBranches(); $branches = array(); foreach ($branches_raw as $branch_raw) { $branch_info = new BranchInfo($branch_raw['name']); $branch_info->setSha1($branch_raw['sha1']); if ($branch_raw['current']) { $branch_info->setCurrent(); } $branches[] = $branch_info; } $name_sha1_map = mpull($branches, 'getSha1', 'getName'); $commits_list = $api->multigetCommitMessages( array_unique(array_values($name_sha1_map)), "%%ct%%n%%an%%n%%s%%n%%b"); //don't ask foreach ($branches as $branch) { $sha1 = $name_sha1_map[$branch->getName()]; $branch->setSha1($sha1); $branch->parseCommitMessage($commits_list[$sha1]); } $branches = msort($branches, 'getCommitTime'); return $branches; } public function __construct($branch_name) { $this->branchName = $branch_name; } public function setSha1($sha1) { $this->sha1 = $sha1; return $this; } public function getSha1() { return $this->sha1; } public function setCurrent() { $this->currentHead = true; } public function isCurrentHead() { return $this->currentHead; } public function setStatus($status) { $this->status = $status; } public function getStatus() { return $this->status; } public function getRevisionID() { return $this->revisionID; } public function getCommitTime() { return $this->commitTime; } public function getCommitSubject() { return $this->commitSubject; } public function getCommitDisplayName() { if ($this->revisionID) { return 'D'.$this->revisionID.': '.$this->commitSubject; } else { return $this->commitSubject; } } public function getCommitAuthor() { return $this->commitAuthor; } public function getName() { return $this->branchName; } /** * Based on the 'git show' output extracts the commit date, author, * subject nad Differential revision . * 'Differential Revision:' * * @param string message output of git show -s --format="format:%ct%n%cn%n%b" */ public function parseCommitMessage($message) { $message_lines = explode("\n", trim($message)); $this->commitTime = $message_lines[0]; $this->commitAuthor = $message_lines[1]; $this->commitSubject = trim($message_lines[2]); $this->revisionID = ArcanistDifferentialCommitMessage::newFromRawCorpus($message) ->getRevisionID(); } public function getFormattedName() { $res = ""; if ($this->currentHead) { $res = '* '; } $res .= $this->branchName; return phutil_console_format('**%s**', $res); } /** * Generates a colored status name */ public function getFormattedStatus() { return phutil_console_format( 'getColorForStatus().'>%s', $this->status); } /** * Assigns a pretty color based on the status */ private function getColorForStatus() { static $status_to_color = array( 'Committed' => 'cyan', 'Needs Review' => 'magenta', 'Needs Revision' => 'red', 'Accepted' => 'green', 'No Revision' => 'blue', 'Abandoned' => 'default', ); return idx($status_to_color, $this->status, 'default'); } } diff --git a/src/configuration/ArcanistConfiguration.php b/src/configuration/ArcanistConfiguration.php index a382ef7a..1a6eadda 100644 --- a/src/configuration/ArcanistConfiguration.php +++ b/src/configuration/ArcanistConfiguration.php @@ -1,123 +1,124 @@ setType('class') ->setName($workflow_class) ->setLibrary('arcanist') ->selectAndLoadSymbols(); if (!$symbols) { return null; } return newv($workflow_class, array()); } public function buildAllWorkflows() { $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setAncestorClass('ArcanistBaseWorkflow') ->setLibrary('arcanist') ->selectAndLoadSymbols(); $workflows = array(); foreach ($symbols as $symbol) { $class = $symbol['name']; $name = preg_replace('/^Arcanist(\w+)Workflow$/', '\1', $class); $name[0] = strtolower($name[0]); $name = preg_replace_callback( '/[A-Z]/', array( 'ArcanistConfiguration', 'replaceClassnameUppers', ), $name); $name = strtolower($name); $workflows[$name] = newv($class, array()); } return $workflows; } public function willRunWorkflow($command, ArcanistBaseWorkflow $workflow) { // This is a hook. } public function didRunWorkflow($command, ArcanistBaseWorkflow $workflow, $err) { // This is a hook. } public function getCustomArgumentsForCommand($command) { return array(); } public static function replaceClassnameHyphens($m) { return strtoupper($m[1]); } public static function replaceClassnameUppers($m) { return '-'.strtolower($m[0]); } } diff --git a/src/difference/__tests__/ArcanistDiffUtilsTestCase.php b/src/difference/__tests__/ArcanistDiffUtilsTestCase.php index 0f4e3764..f34eb7cb 100644 --- a/src/difference/__tests__/ArcanistDiffUtilsTestCase.php +++ b/src/difference/__tests__/ArcanistDiffUtilsTestCase.php @@ -1,96 +1,96 @@ assertEqual( $test[2], ArcanistDiffUtils::buildLevenshteinDifferenceString($test[0], $test[1]) ); } } } diff --git a/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php b/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php index f807221c..1be8f675 100644 --- a/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php +++ b/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php @@ -1,129 +1,129 @@ rawCorpus = $corpus; // Parse older-style "123" fields, or newer-style full-URI fields. // TODO: Remove support for older-style fields. $match = null; if (preg_match('/^Differential Revision:\s*(.*)/im', $corpus, $match)) { $revision_id = trim($match[1]); if (strlen($revision_id)) { if (preg_match('/^D?\d+$/', $revision_id)) { $obj->revisionID = (int)trim($revision_id, 'D'); } else { $uri = new PhutilURI($revision_id); $path = $uri->getPath(); $path = trim($path, '/'); if (preg_match('/^D\d+$/', $path)) { $obj->revisionID = (int)trim($path, 'D'); } else { throw new ArcanistUsageException( "Invalid 'Differential Revision' field. The field should have a ". "Phabricator URI like 'http://phabricator.example.com/D123', ". "but has '{$match[1]}'."); } } } } $pattern = '/^git-svn-id:\s*([^@]+)@(\d+)\s+(.*)$/m'; if (preg_match($pattern, $corpus, $match)) { $obj->gitSVNBaseRevision = $match[1].'@'.$match[2]; $obj->gitSVNBasePath = $match[1]; $obj->gitSVNUUID = $match[3]; } return $obj; } public function getRawCorpus() { return $this->rawCorpus; } public function getRevisionID() { return $this->revisionID; } public function pullDataFromConduit(ConduitClient $conduit) { $result = $conduit->callMethodSynchronous( 'differential.parsecommitmessage', array( 'corpus' => $this->rawCorpus, )); if (!empty($result['errors'])) { throw new ArcanistDifferentialCommitMessageParserException( $result['errors']); } $this->fields = $result['fields']; } public function getFieldValue($key) { if (array_key_exists($key, $this->fields)) { return $this->fields[$key]; } return null; } public function setFieldValue($key, $value) { $this->fields[$key] = $value; return $this; } public function getFields() { return $this->fields; } public function getGitSVNBaseRevision() { return $this->gitSVNBaseRevision; } public function getGitSVNBasePath() { return $this->gitSVNBasePath; } public function getGitSVNUUID() { return $this->gitSVNUUID; } public function getChecksum() { $fields = array_filter($this->fields); ksort($fields); $fields = json_encode($fields); return md5($fields); } } diff --git a/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php b/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php index e4faf5aa..dea1b0eb 100644 --- a/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php +++ b/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php @@ -1,37 +1,37 @@ parserErrors = $errors; parent::__construct(head($errors)); } public function getParserErrors() { return $this->parserErrors; } } diff --git a/src/differential/revision/ArcanistDifferentialRevisionRef.php b/src/differential/revision/ArcanistDifferentialRevisionRef.php index 5a7668b4..03796c68 100644 --- a/src/differential/revision/ArcanistDifferentialRevisionRef.php +++ b/src/differential/revision/ArcanistDifferentialRevisionRef.php @@ -1,60 +1,60 @@ id = $dictionary['id']; $ref->name = $dictionary['name']; $ref->statusName = $dictionary['statusName']; $ref->sourcePath = $dictionary['sourcePath']; return $ref; } protected function __construct() { } public function getID() { return $this->id; } public function getName() { return $this->name; } public function getStatusName() { return $this->statusName; } public function getSourcePath() { return $this->sourcePath; } } diff --git a/src/exception/ArcanistChooseInvalidRevisionException.php b/src/exception/ArcanistChooseInvalidRevisionException.php index f49558d3..459f0cc2 100644 --- a/src/exception/ArcanistChooseInvalidRevisionException.php +++ b/src/exception/ArcanistChooseInvalidRevisionException.php @@ -1,26 +1,26 @@ workingCopy = $working_copy; return $this; } public function getWorkingCopy() { return $this->workingCopy; } public function setPaths($paths) { $this->paths = $paths; return $this; } public function getPaths() { return $this->paths; } public function setPathChangedLines($path, $changed) { if ($changed === null) { $this->changedLines[$path] = null; } else { $this->changedLines[$path] = array_fill_keys($changed, true); } return $this; } public function getPathChangedLines($path) { return idx($this->changedLines, $path); } public function setFileData($data) { $this->fileData = $data + $this->fileData; return $this; } public function setCommitHookMode($mode) { $this->commitHookMode = $mode; return $this; } public function setHookAPI(ArcanistHookAPI $hook_api) { $this->hookAPI = $hook_api; } public function getHookAPI() { return $this->hookAPI; } public function loadData($path) { if (!isset($this->fileData[$path])) { if ($this->getCommitHookMode()) { $this->fileData[$path] = $this->getHookAPI() ->getCurrentFileData($path); } else { $disk_path = $this->getFilePathOnDisk($path); $this->fileData[$path] = Filesystem::readFile($disk_path); } } return $this->fileData[$path]; } public function pathExists($path) { if ($this->getCommitHookMode()) { return (idx($this->fileData, $path) !== null); } else { $disk_path = $this->getFilePathOnDisk($path); return Filesystem::pathExists($disk_path); } } public function getFilePathOnDisk($path) { return Filesystem::resolvePath( $path, $this->getWorkingCopy()->getProjectRoot()); } public function setMinimumSeverity($severity) { $this->minimumSeverity = $severity; return $this; } public function getCommitHookMode() { return $this->commitHookMode; } public function run() { $stopped = array(); $linters = $this->buildLinters(); if (!$linters) { throw new ArcanistNoEffectException("No linters to run."); } $have_paths = false; foreach ($linters as $linter) { if ($linter->getPaths()) { $have_paths = true; break; } } if (!$have_paths) { throw new ArcanistNoEffectException("No paths are lintable."); } foreach ($linters as $linter) { $linter->setEngine($this); if (!$linter->canRun()) { continue; } $paths = $linter->getPaths(); foreach ($paths as $key => $path) { // Make sure each path has a result generated, even if it is empty // (i.e., the file has no lint messages). $result = $this->getResultForPath($path); if (isset($stopped[$path])) { unset($paths[$key]); } } $paths = array_values($paths); if ($paths) { $linter->willLintPaths($paths); foreach ($paths as $path) { $linter->willLintPath($path); $linter->lintPath($path); if ($linter->didStopAllLinters()) { $stopped[$path] = true; } } } $minimum = $this->minimumSeverity; foreach ($linter->getLintMessages() as $message) { if (!ArcanistLintSeverity::isAtLeastAsSevere($message, $minimum)) { continue; } // When a user runs "arc lint", we default to raising only warnings on // lines they have changed (errors are still raised anywhere in the // file). The list of $changed lines may be null, to indicate that the // path is a directory or a binary file so we should not exclude // warnings. $changed = $this->getPathChangedLines($message->getPath()); if ($changed !== null && !$message->isError() && $message->getLine()) { if (empty($changed[$message->getLine()])) { continue; } } $result = $this->getResultForPath($message->getPath()); $result->addMessage($message); } } foreach ($this->results as $path => $result) { $disk_path = $this->getFilePathOnDisk($path); $result->setFilePathOnDisk($disk_path); if (isset($this->fileData[$path])) { $result->setData($this->fileData[$path]); } else if ($disk_path && Filesystem::pathExists($disk_path)) { // TODO: this may cause us to, e.g., load a large binary when we only // raised an error about its filename. We could refine this by looking // through the lint messages and doing this load only if any of them // have original/replacement text or something like that. try { $this->fileData[$path] = Filesystem::readFile($disk_path); $result->setData($this->fileData[$path]); } catch (FilesystemException $ex) { // Ignore this, it's noncritical that we access this data and it // might be unreadable or a directory or whatever else for plenty // of legitimate reasons. } } } return $this->results; } abstract protected function buildLinters(); private function getResultForPath($path) { if (empty($this->results[$path])) { $result = new ArcanistLintResult(); $result->setPath($path); $this->results[$path] = $result; } return $this->results[$path]; } public function getLineAndCharFromOffset($path, $offset) { if (!isset($this->charToLine[$path])) { $char_to_line = array(); $line_to_first_char = array(); $lines = explode("\n", $this->loadData($path)); $line_number = 0; $line_start = 0; foreach ($lines as $line) { $len = strlen($line) + 1; // Account for "\n". $line_to_first_char[] = $line_start; $line_start += $len; for ($ii = 0; $ii < $len; $ii++) { $char_to_line[] = $line_number; } $line_number++; } $this->charToLine[$path] = $char_to_line; $this->lineToFirstChar[$path] = $line_to_first_char; } $line = $this->charToLine[$path][$offset]; $char = $offset - $this->lineToFirstChar[$path][$line]; return array($line, $char); } } diff --git a/src/lint/engine/comprehensive/ComprehensiveLintEngine.php b/src/lint/engine/comprehensive/ComprehensiveLintEngine.php index 2aea781a..6e0c0d26 100644 --- a/src/lint/engine/comprehensive/ComprehensiveLintEngine.php +++ b/src/lint/engine/comprehensive/ComprehensiveLintEngine.php @@ -1,148 +1,148 @@ getPaths(); // This needs to go first so that changes to generated files cause module // linting. This linter also operates on removed files, because removing // a file changes the static properties of a module. $module_linter = new ArcanistPhutilModuleLinter(); $linters[] = $module_linter; foreach ($paths as $path) { $module_linter->addPath($path); } // Remaining lint engines operate on file contents and ignore removed // files. foreach ($paths as $key => $path) { if (!$this->pathExists($path)) { unset($paths[$key]); } if (preg_match('@^externals/@', $path)) { // Third-party stuff lives in /externals/; don't run lint engines // against it. unset($paths[$key]); } } $generated_linter = new ArcanistGeneratedLinter(); $linters[] = $generated_linter; $nolint_linter = new ArcanistNoLintLinter(); $linters[] = $nolint_linter; $text_linter = new ArcanistTextLinter(); $linters[] = $text_linter; foreach ($paths as $path) { $is_text = false; if (preg_match('/\.(php|css|hpp|cpp|l|y)$/', $path)) { $is_text = true; } if ($is_text) { $generated_linter->addPath($path); $generated_linter->addData($path, $this->loadData($path)); $nolint_linter->addPath($path); $nolint_linter->addData($path, $this->loadData($path)); $text_linter->addPath($path); $text_linter->addData($path, $this->loadData($path)); } } $name_linter = new ArcanistFilenameLinter(); $linters[] = $name_linter; foreach ($paths as $path) { $name_linter->addPath($path); } $xhpast_linter = new ArcanistXHPASTLinter(); $linters[] = $xhpast_linter; foreach ($paths as $path) { if (preg_match('/\.php$/', $path)) { $xhpast_linter->addPath($path); $xhpast_linter->addData($path, $this->loadData($path)); } } $linters = array_merge($linters, $this->buildLicenseLinters($paths)); $linters = array_merge($linters, $this->buildPythonLinters($paths)); $linters = array_merge($linters, $this->buildJSLinters($paths)); return $linters; } public function buildLicenseLinters($paths) { $license_linter = new ArcanistApacheLicenseLinter(); $linters = array(); $linters[] = $license_linter; foreach ($paths as $path) { if (preg_match('/\.(php|cpp|hpp|l|y)$/', $path)) { if (!preg_match('@^externals/@', $path)) { $license_linter->addPath($path); $license_linter->addData($path, $this->loadData($path)); } } } return $linters; } public function buildPythonLinters($paths) { $pyflakes_linter = new ArcanistPyFlakesLinter(); $pep8_linter = new ArcanistPEP8Linter(); $linters = array(); $linters[] = $pyflakes_linter; $linters[] = $pep8_linter; foreach ($paths as $path) { if (preg_match('/\.py$/', $path)) { $pyflakes_linter->addPath($path); $pyflakes_linter->addData($path, $this->loadData($path)); $pep8_linter->addPath($path); $pep8_linter->addData($path, $this->loadData($path)); } } return $linters; } public function buildJSLinters($paths) { $js_linter = new ArcanistJSHintLinter(); $linters = array(); $linters[] = $js_linter; foreach ($paths as $path) { if (preg_match('/\.js$/', $path)) { $js_linter->addPath($path); $js_linter->addData($path, $this->loadData($path)); } } return $linters; } } diff --git a/src/lint/engine/example/ExampleLintEngine.php b/src/lint/engine/example/ExampleLintEngine.php index d99e7e81..b6106d79 100644 --- a/src/lint/engine/example/ExampleLintEngine.php +++ b/src/lint/engine/example/ExampleLintEngine.php @@ -1,72 +1,72 @@ getPaths(); // The ArcanistPyLintLinter runs "PyLint" (an open source python linter) on // files you give it. There are several linters available by default like // this one which you can use out of the box, or you can write your own. // Linters are responsible for actually analyzing the contents of a file // and raising warnings and errors. $pylint_linter = new ArcanistPyLintLinter(); foreach ($paths as $path) { if (!preg_match('/\.py$/', $path)) { // This isn't a python file, so don't try to apply the PyLint linter // to it. continue; } if (preg_match('@^externals/@', $path)) { // This is just an example of how to exclude a path so it doesn't get // linted. If you put third-party code in an externals/ directory, you // can just have your lint engine ignore it. continue; } // Add the path, to tell the linter it should examine the source code // to try to find problems. $pylint_linter->addPath($path); } // We only built one linter, but you can build more than one (e.g., a // Javascript linter for JS), and return a list of linters to execute. You // can also add a path to more than one linter (for example, if you want // to run a Python linter and a more general text linter on every .py file). return array( $pylint_linter, ); } } diff --git a/src/lint/engine/liberate/ArcanistLiberateLintEngine.php b/src/lint/engine/liberate/ArcanistLiberateLintEngine.php index 111ad43a..3b1d9e47 100644 --- a/src/lint/engine/liberate/ArcanistLiberateLintEngine.php +++ b/src/lint/engine/liberate/ArcanistLiberateLintEngine.php @@ -1,38 +1,38 @@ getPaths() as $path) { $module_linter->addPath($path); } return array($module_linter); } } diff --git a/src/lint/engine/phutil/PhutilLintEngine.php b/src/lint/engine/phutil/PhutilLintEngine.php index ac4ecdd8..42d81184 100644 --- a/src/lint/engine/phutil/PhutilLintEngine.php +++ b/src/lint/engine/phutil/PhutilLintEngine.php @@ -1,118 +1,120 @@ getPaths(); // This needs to go first so that changes to generated files cause module // linting. This linter also operates on removed files, because removing // a file changes the static properties of a module. $module_linter = new ArcanistPhutilModuleLinter(); $linters[] = $module_linter; foreach ($paths as $path) { $module_linter->addPath($path); } // Remaining lint engines operate on file contents and ignore removed // files. foreach ($paths as $key => $path) { if (!$this->pathExists($path)) { unset($paths[$key]); } if (preg_match('@^externals/@', $path)) { // Third-party stuff lives in /externals/; don't run lint engines // against it. unset($paths[$key]); } } $generated_linter = new ArcanistGeneratedLinter(); $linters[] = $generated_linter; $nolint_linter = new ArcanistNoLintLinter(); $linters[] = $nolint_linter; $text_linter = new ArcanistTextLinter(); $linters[] = $text_linter; $spelling_linter = new ArcanistSpellingLinter(); $linters[] = $spelling_linter; foreach ($paths as $path) { $is_text = false; if (preg_match('/\.(php|css|js|hpp|cpp|l|y)$/', $path)) { $is_text = true; } if ($is_text) { $generated_linter->addPath($path); $generated_linter->addData($path, $this->loadData($path)); $nolint_linter->addPath($path); $nolint_linter->addData($path, $this->loadData($path)); $text_linter->addPath($path); $text_linter->addData($path, $this->loadData($path)); $spelling_linter->addPath($path); $spelling_linter->addData($path, $this->loadData($path)); } } $name_linter = new ArcanistFilenameLinter(); $linters[] = $name_linter; foreach ($paths as $path) { $name_linter->addPath($path); } $xhpast_linter = new ArcanistXHPASTLinter(); $xhpast_linter->setCustomSeverityMap( array( ArcanistXHPASTLinter::LINT_RAGGED_CLASSTREE_EDGE => ArcanistLintSeverity::SEVERITY_WARNING, )); $license_linter = new ArcanistApacheLicenseLinter(); $linters[] = $xhpast_linter; $linters[] = $license_linter; foreach ($paths as $path) { if (preg_match('/\.php$/', $path)) { $xhpast_linter->addPath($path); $xhpast_linter->addData($path, $this->loadData($path)); } } foreach ($paths as $path) { if (preg_match('/\.(php|cpp|hpp|l|y)$/', $path)) { if (!preg_match('@^externals/@', $path)) { $license_linter->addPath($path); $license_linter->addData($path, $this->loadData($path)); } } } return $linters; } } diff --git a/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php b/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php index f150572a..ff0336da 100644 --- a/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php +++ b/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php @@ -1,69 +1,69 @@ executeTestsInDirectory( dirname(__FILE__).'/data/', $linter, $working_copy); } protected function compareTransform($expected, $actual) { $expected = str_replace('YYYY', date('Y'), $expected); return parent::compareTransform($expected, $actual); } } diff --git a/src/lint/linter/base/ArcanistLinter.php b/src/lint/linter/base/ArcanistLinter.php index de9db19b..366c0a69 100644 --- a/src/lint/linter/base/ArcanistLinter.php +++ b/src/lint/linter/base/ArcanistLinter.php @@ -1,199 +1,200 @@ customSeverityMap = $map; return $this; } public function getActivePath() { return $this->activePath; } public function stopAllLinters() { $this->stopAllLinters = true; return $this; } public function didStopAllLinters() { return $this->stopAllLinters; } public function addPath($path) { $this->paths[$path] = $path; return $this; } public function getPaths() { return array_values($this->paths); } public function addData($path, $data) { $this->data[$path] = $data; return $this; } protected function getData($path) { if (!array_key_exists($path, $this->data)) { $this->data[$path] = $this->getEngine()->loadData($path); } return $this->data[$path]; } public function setEngine(ArcanistLintEngine $engine) { $this->engine = $engine; return $this; } protected function getEngine() { return $this->engine; } public function getLintMessageFullCode($short_code) { return $this->getLinterName().$short_code; } public function getLintMessageSeverity($code) { $map = $this->customSeverityMap; if (isset($map[$code])) { return $map[$code]; } $map = $this->getLintSeverityMap(); if (isset($map[$code])) { return $map[$code]; } return ArcanistLintSeverity::SEVERITY_ERROR; } public function isMessageEnabled($code) { return ($this->getLintMessageSeverity($code) !== ArcanistLintSeverity::SEVERITY_DISABLED); } public function getLintMessageName($code) { $map = $this->getLintNameMap(); if (isset($map[$code])) { return $map[$code]; } return "Unknown lint message!"; } protected function addLintMessage(ArcanistLintMessage $message) { $this->messages[] = $message; return $message; } public function getLintMessages() { return $this->messages; } protected function raiseLintAtLine( $line, $char, $code, $desc, $original = null, $replacement = null) { $dict = array( 'path' => $this->getActivePath(), 'line' => $line, 'char' => $char, 'code' => $this->getLintMessageFullCode($code), 'severity' => $this->getLintMessageSeverity($code), 'name' => $this->getLintMessageName($code), 'description' => $desc, ); if ($original !== null) { $dict['original'] = $original; } if ($replacement !== null) { $dict['replacement'] = $replacement; } return $this->addLintMessage(ArcanistLintMessage::newFromDictionary($dict)); } protected function raiseLintAtPath( $code, $desc) { return $this->raiseLintAtLine(null, null, $code, $desc, null, null); } protected function raiseLintAtOffset( $offset, $code, $desc, $original = null, $replacement = null) { $path = $this->getActivePath(); $engine = $this->getEngine(); if ($offset === null) { $line = null; $char = null; } else { list($line, $char) = $engine->getLineAndCharFromOffset($path, $offset); } return $this->raiseLintAtLine( $line + 1, $char + 1, $code, $desc, $original, $replacement); } public function willLintPath($path) { $this->stopAllLinters = false; $this->activePath = $path; } public function canRun() { return true; } abstract public function willLintPaths(array $paths); abstract public function lintPath($path); abstract public function getLinterName(); abstract public function getLintSeverityMap(); abstract public function getLintNameMap(); } diff --git a/src/lint/linter/conduit/ArcanistConduitLinter.php b/src/lint/linter/conduit/ArcanistConduitLinter.php index 97d1eaf2..68c03132 100644 --- a/src/lint/linter/conduit/ArcanistConduitLinter.php +++ b/src/lint/linter/conduit/ArcanistConduitLinter.php @@ -1,110 +1,110 @@ must match passed in path. * 'line' * 'char' * 'code' * 'severity' => Must match a constant in ArcanistLintSeverity. * 'name' * 'description' * 'original' & 'replacement' => optional patch information * * This class is intended for customization via instantiation, not via * subclassing. */ -class ArcanistConduitLinter extends ArcanistLinter { +final class ArcanistConduitLinter extends ArcanistLinter { const CONDUIT_METHOD = 'lint.getalllint'; private $conduitURI; private $linterName; private $lintByPath; // array(/pa/th/ => ), valid after willLintPaths(). public function __construct($conduit_uri, $linter_name) { $this->conduitURI = $conduit_uri; $this->linterName = $linter_name; } public function willLintPaths(array $paths) { // Load all file path data into $this->data. array_map(array($this, 'getData'), $paths); $conduit = new ConduitClient($this->conduitURI); $this->lintByPath = $conduit->callMethodSynchronous( self::CONDUIT_METHOD, array( 'file_contents' => $this->data, ) ); } public function lintPath($path) { $lint_for_path = idx($this->lintByPath, $path); if (!$lint_for_path) { return; } foreach ($lint_for_path as $lint) { $this->addLintMessage(ArcanistLintMessage::newFromDictionary($lint)); } } public function getLinterName() { return $this->linterName; } public function getLintSeverityMap() { // The rationale here is that this class will only be used for custom // linting in installations. No two server endpoints will be the same across // different instantiations. Therefore, the server can handle all severity // customization directly. throw new ArcanistUsageException( 'ArcanistConduitLinter does not support client-side severity '. 'customization.' ); } public function getLintNameMap() { // See getLintSeverityMap for rationale. throw new ArcanistUsageException( 'ArcanistConduitLinter does not support a name map.' ); } } diff --git a/src/lint/linter/filename/ArcanistFilenameLinter.php b/src/lint/linter/filename/ArcanistFilenameLinter.php index f5630c03..a529bd74 100644 --- a/src/lint/linter/filename/ArcanistFilenameLinter.php +++ b/src/lint/linter/filename/ArcanistFilenameLinter.php @@ -1,55 +1,55 @@ 'Bad Filename', ); } public function lintPath($path) { if (!preg_match('@^[a-z0-9./_-]+$@i', $path)) { $this->raiseLintAtPath( self::LINT_BAD_FILENAME, 'Name files using only letters, numbers, period, hyphen and '. 'underscore.'); } } } diff --git a/src/lint/linter/generated/ArcanistGeneratedLinter.php b/src/lint/linter/generated/ArcanistGeneratedLinter.php index 91e30eff..84ee58be 100644 --- a/src/lint/linter/generated/ArcanistGeneratedLinter.php +++ b/src/lint/linter/generated/ArcanistGeneratedLinter.php @@ -1,51 +1,51 @@ getData($path); if (preg_match('/@'.'generated/', $data)) { $this->stopAllLinters(); } } } diff --git a/src/lint/linter/jshint/ArcanistJSHintLinter.php b/src/lint/linter/jshint/ArcanistJSHintLinter.php index db4c53a2..6d25a4dd 100644 --- a/src/lint/linter/jshint/ArcanistJSHintLinter.php +++ b/src/lint/linter/jshint/ArcanistJSHintLinter.php @@ -1,170 +1,170 @@ ArcanistLintSeverity::SEVERITY_ERROR ); } public function getLintNameMap() { return array( self::JSHINT_ERROR => "JSHint Error" ); } public function getJSHintOptions() { $working_copy = $this->getEngine()->getWorkingCopy(); $options = '--reporter '.dirname(realpath(__FILE__)).'/reporter.js'; $config = $working_copy->getConfig('lint.jshint.config'); if ($config !== null) { $config = Filesystem::resolvePath($config, $working_copy->getProjectRoot()); if (!Filesystem::pathExists($config)) { throw new ArcanistUsageException( "Unable to find custom options file defined by 'lint.jshint.config'. ". "Make sure that the path is correct."); } $options .= ' --config '.$config; } return $options; } private function getJSHintPath() { $working_copy = $this->getEngine()->getWorkingCopy(); $prefix = $working_copy->getConfig('lint.jshint.prefix'); $bin = $working_copy->getConfig('lint.jshint.bin'); if ($bin === null) { $bin = "jshint"; } if ($prefix !== null) { $bin = $prefix."/".$bin; if (!Filesystem::pathExists($bin)) { throw new ArcanistUsageException( "Unable to find JSHint binary in a specified directory. Make sure ". "that 'lint.jshint.prefix' and 'lint.jshint.bin' keys are set ". "correctly. If you'd rather use a copy of JSHint installed globally, ". "you can just remove these keys from your .arcconfig"); } return $bin; } // Look for globally installed JSHint list($err) = exec_manual('which %s', $bin); if ($err) { throw new ArcanistUsageException( "JSHint does not appear to be installed on this system. Install it ". "(e.g., with 'npm install jshint -g') or configure ". "'lint.jshint.prefix' in your .arcconfig to point to the directory ". "where it resides."); } return $bin; } public function willLintPaths(array $paths) { $jshint_bin = $this->getJSHintPath(); $jshint_options = $this->getJSHintOptions(); $futures = array(); foreach ($paths as $path) { $filepath = $this->getEngine()->getFilePathOnDisk($path); $futures[$path] = new ExecFuture("{$jshint_bin} {$filepath} ${jshint_options}"); } foreach (Futures($futures)->limit(8) as $path => $future) { $this->results[$path] = $future->resolve(); } } public function lintPath($path) { list($rc, $stdout) = $this->results[$path]; if ($rc === 0) { return; } $errors = json_decode($stdout); if (!is_array($errors)) { // Something went wrong and we can't decode the output. Exit abnormally. throw new ArcanistUsageException( "JSHint returned output we can't parse. Check that your JSHint installation.\n". "Output:\n". $stdout); } foreach ($errors as $err) { $this->raiseLintAtLine( $err->line, $err->col, self::JSHINT_ERROR, $err->reason); } } } diff --git a/src/lint/linter/nolint/ArcanistNoLintLinter.php b/src/lint/linter/nolint/ArcanistNoLintLinter.php index 705446ee..a6c4b6e4 100644 --- a/src/lint/linter/nolint/ArcanistNoLintLinter.php +++ b/src/lint/linter/nolint/ArcanistNoLintLinter.php @@ -1,50 +1,50 @@ getData($path); if (preg_match('/@'.'nolint/', $data)) { $this->stopAllLinters(); } } } diff --git a/src/lint/linter/pep8/ArcanistPEP8Linter.php b/src/lint/linter/pep8/ArcanistPEP8Linter.php index d05bc9b6..9e5aa6b2 100644 --- a/src/lint/linter/pep8/ArcanistPEP8Linter.php +++ b/src/lint/linter/pep8/ArcanistPEP8Linter.php @@ -1,134 +1,134 @@ getEngine()->getWorkingCopy(); $options = $working_copy->getConfig('lint.pep8.options'); if ($options === null) { // W293 (blank line contains whitespace) is redundant when used // alongside TXT6, causing pain to python programmers. return '--ignore=W293'; } 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 { 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); } $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]); if ($matches[4][0] == 'E') { $message->setSeverity(ArcanistLintSeverity::SEVERITY_ERROR); } else { $message->setSeverity(ArcanistLintSeverity::SEVERITY_WARNING); } $this->addLintMessage($message); } } } diff --git a/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php b/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php index b8d56980..71e3f785 100644 --- a/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php +++ b/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php @@ -1,529 +1,529 @@ 'Use of Undeclared Class', self::LINT_UNDECLARED_FUNCTION => 'Use of Undeclared Function', self::LINT_UNDECLARED_INTERFACE => 'Use of Undeclared Interface', self::LINT_UNDECLARED_SOURCE => 'Use of Nonexistent File', self::LINT_UNUSED_SOURCE => 'Unused Source', self::LINT_UNUSED_MODULE => 'Unused Module', self::LINT_INIT_REBUILD => 'Rebuilt __init__.php File', self::LINT_UNKNOWN_CLASS => 'Unknown Class', self::LINT_UNKNOWN_FUNCTION => 'Unknown Function', self::LINT_ANALYZER_SIGNATURE => 'Analyzer: Bad Call Signature', self::LINT_ANALYZER_DYNAMIC => 'Analyzer: Dynamic Dependency', self::LINT_ANALYZER_NO_INIT => 'Analyzer: No __init__.php File', self::LINT_ANALYZER_MULTIPLE_CLASSES => 'Analyzer: File Declares Multiple Classes', ); } public function getLinterName() { return 'PHU'; } public function getLintSeverityMap() { return array( self::LINT_ANALYZER_DYNAMIC => ArcanistLintSeverity::SEVERITY_WARNING, ); } private $moduleInfo = array(); private $unknownClasses = array(); private $unknownFunctions = array(); private function setModuleInfo($key, array $info) { $this->moduleInfo[$key] = $info; } private function getModulePathOnDisk($key) { $info = $this->moduleInfo[$key]; return $info['root'].'/'.$info['module']; } private function getModuleDisplayName($key) { $info = $this->moduleInfo[$key]; return $info['module']; } private function isPhutilLibraryMetadata($path) { $file = basename($path); return !strncmp('__phutil_library_', $file, strlen('__phutil_library_')); } public function willLintPaths(array $paths) { if ($paths) { if (!xhpast_is_available()) { throw new Exception(xhpast_get_build_instructions()); } } $modules = array(); $moduleinfo = array(); $project_root = $this->getEngine()->getWorkingCopy()->getProjectRoot(); foreach ($paths as $path) { $absolute_path = $project_root.'/'.$path; $library_root = phutil_get_library_root_for_path($absolute_path); if (!$library_root) { continue; } if ($this->isPhutilLibraryMetadata($path)) { continue; } $library_name = phutil_get_library_name_for_root($library_root); if (!is_dir($path)) { $path = dirname($path); } $path = Filesystem::resolvePath( $path, $project_root); if ($path == $library_root) { continue; } $module_name = Filesystem::readablePath($path, $library_root); $module_key = $library_name.':'.$module_name; if (empty($modules[$module_key])) { $modules[$module_key] = $module_key; $this->setModuleInfo($module_key, array( 'library' => $library_name, 'root' => $library_root, 'module' => $module_name, )); } } if (!$modules) { return; } $modules = array_keys($modules); $arc_root = phutil_get_library_root('arcanist'); $bin = dirname($arc_root).'/scripts/phutil_analyzer.php'; $futures = array(); foreach ($modules as $mkey => $key) { $disk_path = $this->getModulePathOnDisk($key); if (Filesystem::pathExists($disk_path)) { $futures[$key] = new ExecFuture( '%s %s', $bin, $disk_path); } else { // This can occur in git when you add a module in HEAD and then remove // it in unstaged changes in the working copy. Just ignore it. unset($modules[$mkey]); } } $requirements = array(); foreach (Futures($futures)->limit(16) as $key => $future) { $requirements[$key] = $future->resolveJSON(); } $dependencies = array(); $futures = array(); foreach ($requirements as $key => $requirement) { foreach ($requirement['messages'] as $message) { list($where, $text, $code, $description) = $message; if ($where) { $where = array($where); } $this->raiseLintInModule( $key, $code, $description, $where, $text); } foreach ($requirement['requires']['module'] as $req_module => $where) { if (isset($requirements[$req_module])) { $dependencies[$req_module] = $requirements[$req_module]; } else { list($library_name, $module_name) = explode(':', $req_module); $library_root = phutil_get_library_root($library_name); $this->setModuleInfo($req_module, array( 'library' => $library_name, 'root' => $library_root, 'module' => $module_name, )); $disk_path = $this->getModulePathOnDisk($req_module); if (Filesystem::pathExists($disk_path)) { $futures[$req_module] = new ExecFuture( '%s %s', $bin, $disk_path); } else { $dependencies[$req_module] = array(); } } } } foreach (Futures($futures)->limit(16) as $key => $future) { $dependencies[$key] = $future->resolveJSON(); } foreach ($requirements as $key => $spec) { $deps = array_intersect_key( $dependencies, $spec['requires']['module']); $this->lintModule($key, $spec, $deps); } } private function lintModule($key, $spec, $deps) { $resolvable = array(); $need_classes = array(); $need_functions = array(); $drop_modules = array(); $used = array(); static $types = array( 'class' => self::LINT_UNDECLARED_CLASS, 'interface' => self::LINT_UNDECLARED_INTERFACE, 'function' => self::LINT_UNDECLARED_FUNCTION, ); foreach ($types as $type => $lint_code) { foreach ($spec['requires'][$type] as $name => $places) { $declared = $this->checkDependency( $type, $name, $deps); if (!$declared) { $module = $this->getModuleDisplayName($key); $message = $this->raiseLintInModule( $key, $lint_code, "Module '{$module}' uses {$type} '{$name}' but does not include ". "any module which declares it.", $places); if ($type == 'class' || $type == 'interface') { $loader = new PhutilSymbolLoader(); $loader->setType($type); $loader->setName($name); $symbols = $loader->selectSymbolsWithoutLoading(); if ($symbols) { $class_spec = reset($symbols); try { $loader->selectAndLoadSymbols(); $loaded = true; } catch (PhutilMissingSymbolException $ex) { $loaded = false; } catch (PhutilBootloaderException $ex) { $loaded = false; } if ($loaded) { $resolvable[] = $message; $need_classes[$name] = $class_spec; } else { if (empty($this->unknownClasses[$name])) { $this->unknownClasses[$name] = true; $library = $class_spec['library']; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_CLASS, "Class '{$name}' exists in the library map for library ". "'{$library}', but could not be loaded. You may need to ". "rebuild the library map.", $places); } } } else { if (empty($this->unknownClasses[$name])) { $this->unknownClasses[$name] = true; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_CLASS, "Class '{$name}' could not be found in any known library. ". "You may need to rebuild the map for the library which ". "contains it.", $places); } } } else { $loader = new PhutilSymbolLoader(); $loader->setType($type); $loader->setName($name); $symbols = $loader->selectSymbolsWithoutLoading(); if ($symbols) { $func_spec = reset($symbols); try { $loader->selectAndLoadSymbols(); $loaded = true; } catch (PhutilMissingSymbolException $ex) { $loaded = false; } catch (PhutilBootloaderException $ex) { $loaded = false; } if ($loaded) { $resolvable[] = $message; $need_functions[$name] = $func_spec; } else { if (empty($this->unknownFunctions[$name])) { $this->unknownFunctions[$name] = true; $library = $func_spec['library']; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_FUNCTION, "Function '{$name}' exists in the library map for library ". "'{$library}', but could not be loaded. You may need to ". "rebuild the library map.", $places); } } } else { if (empty($this->unknownFunctions[$name])) { $this->unknownFunctions[$name] = true; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_FUNCTION, "Function '{$name}' could not be found in any known ". "library. You may need to rebuild the map for the library ". "which contains it.", $places); } } } } $used[$declared] = true; } } $unused = array_diff_key($deps, $used); foreach ($unused as $unused_module_key => $ignored) { $module = $this->getModuleDisplayName($key); $unused_module = $this->getModuleDisplayName($unused_module_key); $resolvable[] = $this->raiseLintInModule( $key, self::LINT_UNUSED_MODULE, "Module '{$module}' requires module '{$unused_module}' but does not ". "use anything it declares.", $spec['requires']['module'][$unused_module_key]); $drop_modules[] = $unused_module_key; } foreach ($spec['requires']['source'] as $file => $where) { if (empty($spec['declares']['source'][$file])) { $module = $this->getModuleDisplayName($key); $resolvable[] = $this->raiseLintInModule( $key, self::LINT_UNDECLARED_SOURCE, "Module '{$module}' requires source '{$file}', but it does not ". "exist.", $where); } } foreach ($spec['declares']['source'] as $file => $ignored) { if (empty($spec['requires']['source'][$file])) { $module = $this->getModuleDisplayName($key); $resolvable[] = $this->raiseLintInModule( $key, self::LINT_UNUSED_SOURCE, "Module '{$module}' does not include source file '{$file}'.", null); } } if ($resolvable) { $new_file = $this->buildNewModuleInit( $key, $spec, $need_classes, $need_functions, $drop_modules); $init_path = $this->getModulePathOnDisk($key).'/__init__.php'; $root = $this->getEngine()->getWorkingCopy()->getProjectRoot(); $try_path = Filesystem::readablePath($init_path, $root); $full_path = Filesystem::resolvePath($try_path, $root); if (Filesystem::pathExists($full_path)) { $init_path = $try_path; $old_file = Filesystem::readFile($full_path); } else { $old_file = ''; } $this->willLintPath($init_path); $message = $this->raiseLintAtOffset( null, self::LINT_INIT_REBUILD, "This generated phutil '__init__.php' file is suggested to address ". "lint problems with static dependencies in the module.", $old_file, $new_file); $message->setDependentMessages($resolvable); foreach ($resolvable as $message) { $message->setObsolete(true); } $message->setGenerateFile(true); } } private function buildNewModuleInit( $key, $spec, $need_classes, $need_functions, $drop_modules) { $init = array(); $init[] = ' $class_spec) { $modules[$class_spec['library'].':'.$class_spec['module']] = true; } foreach ($need_functions as $need => $func_spec) { $modules[$func_spec['library'].':'.$func_spec['module']] = true; } ksort($modules); $last = null; foreach ($modules as $module_key => $ignored) { if (is_array($ignored)) { $in_init = false; $in_file = false; foreach ($ignored as $where) { list($file, $line) = explode(':', $where); if ($file == '__init__.php') { $in_init = true; } else { $in_file = true; } } if ($in_file && !$in_init) { // If this is a runtime include, don't try to put it in the // __init__ file. continue; } } list($library, $module_name) = explode(':', $module_key); if ($last != $library) { $last = $library; if ($last != null) { $init[] = null; } } $library = "'".addcslashes($library, "'\\")."'"; $module_name = "'".addcslashes($module_name, "'\\")."'"; $init[] = "phutil_require_module({$library}, {$module_name});"; } $init[] = null; $init[] = null; $files = array_keys($spec['declares']['source']); sort($files); foreach ($files as $file) { $file = "'".addcslashes($file, "'\\")."'"; $init[] = "phutil_require_source({$file});"; } $init[] = null; return implode("\n", $init); } private function checkDependency($type, $name, $deps) { foreach ($deps as $key => $dep) { if (isset($dep['declares'][$type][$name])) { return $key; } } return false; } public function raiseLintInModule($key, $code, $desc, $places, $text = null) { if ($places) { foreach ($places as $place) { list($file, $offset) = explode(':', $place); $this->willLintPath( Filesystem::readablePath( $this->getModulePathOnDisk($key).'/'.$file, $this->getEngine()->getWorkingCopy()->getProjectRoot())); return $this->raiseLintAtOffset( $offset, $code, $desc, $text); } } else { $this->willLintPath($this->getModuleDisplayName($key)); return $this->raiseLintAtPath( $code, $desc); } } public function lintPath($path) { return; } } diff --git a/src/lint/linter/pyflakes/ArcanistPyFlakesLinter.php b/src/lint/linter/pyflakes/ArcanistPyFlakesLinter.php index 3b2dfbaa..921db13d 100644 --- a/src/lint/linter/pyflakes/ArcanistPyFlakesLinter.php +++ b/src/lint/linter/pyflakes/ArcanistPyFlakesLinter.php @@ -1,113 +1,113 @@ getEngine()->getWorkingCopy(); $pyflakes_path = $working_copy->getConfig('lint.pyflakes.path'); $pyflakes_prefix = $working_copy->getConfig('lint.pyflakes.prefix'); // Default to just finding pyflakes in the users path $pyflakes_bin = 'pyflakes'; $python_path = ''; // If a pyflakes path was specified, then just use that as the // pyflakes binary and assume that the libraries will be imported // correctly. // // If no pyflakes path was specified and a pyflakes prefix was // specified, then use the binary from this prefix and add it to // the PYTHONPATH environment variable so that the libs are imported // correctly. This is useful when pyflakes is installed into a // non-default location. if ($pyflakes_path !== null) { $pyflakes_bin = $pyflakes_path; } else if ($pyflakes_prefix !== null) { $pyflakes_bin = $pyflakes_prefix.'/bin/pyflakes'; $python_path = $pyflakes_prefix.'/lib/python2.6/site-packages:'; } $options = $this->getPyFlakesOptions(); $f = new ExecFuture( "/usr/bin/env PYTHONPATH=%s\$PYTHONPATH ". "{$pyflakes_bin} {$options}", $python_path); $f->write($this->getData($path)); try { list($stdout, $_) = $f->resolvex(); } catch (CommandException $e) { // PyFlakes will return an exit code of 1 if warnings/errors // are found but print nothing to stderr in this case. Therefore, // if we see any output on stderr or a return code other than 1 or 0, // pyflakes failed. if ($e->getError() !== 1 || $e->getStderr() !== '') { throw $e; } else { $stdout = $e->getStdout(); } } $lines = explode("\n", $stdout); $messages = array(); foreach ($lines as $line) { $matches = null; if (!preg_match('/^(.*?):(\d+): (.*)$/', $line, $matches)) { continue; } foreach ($matches as $key => $match) { $matches[$key] = trim($match); } $message = new ArcanistLintMessage(); $message->setPath($path); $message->setLine($matches[2]); $message->setCode($this->getLinterName()); $message->setDescription($matches[3]); $message->setSeverity(ArcanistLintSeverity::SEVERITY_WARNING); $this->addLintMessage($message); } } } diff --git a/src/lint/linter/pylint/ArcanistPyLintLinter.php b/src/lint/linter/pylint/ArcanistPyLintLinter.php index 51c48fdd..0a01c64e 100644 --- a/src/lint/linter/pylint/ArcanistPyLintLinter.php +++ b/src/lint/linter/pylint/ArcanistPyLintLinter.php @@ -1,261 +1,261 @@ getEngine()->getWorkingCopy(); $error_regexp = $working_copy->getConfig('lint.pylint.codes.error'); $warning_regexp = $working_copy->getConfig('lint.pylint.codes.warning'); $advice_regexp = $working_copy->getConfig('lint.pylint.codes.advice'); if (!$error_regexp && !$warning_regexp && !$advice_regexp) { throw new ArcanistUsageException( "You are invoking the PyLint linter but have not configured any of ". "'lint.pylint.codes.error', 'lint.pylint.codes.warning', or ". "'lint.pylint.codes.advice'. Consult the documentation for ". "ArcanistPyLintLinter."); } $code_map = array( ArcanistLintSeverity::SEVERITY_ERROR => $error_regexp, ArcanistLintSeverity::SEVERITY_WARNING => $warning_regexp, ArcanistLintSeverity::SEVERITY_ADVICE => $advice_regexp, ); foreach ($code_map as $sev => $codes) { if ($codes === null) { continue; } if (!is_array($codes)) { $codes = array($codes); } foreach ($codes as $code_re) { if (preg_match("/{$code_re}/", $code)) { return $sev; } } } // If the message code doesn't match any of the provided regex's, // then just disable it. return ArcanistLintSeverity::SEVERITY_DISABLED; } private function getPyLintPath() { $pylint_bin = "pylint"; // Use the PyLint prefix specified in the config file $working_copy = $this->getEngine()->getWorkingCopy(); $prefix = $working_copy->getConfig('lint.pylint.prefix'); if ($prefix !== null) { $pylint_bin = $prefix."/bin/".$pylint_bin; } if (!Filesystem::pathExists($pylint_bin)) { list($err) = exec_manual('which %s', $pylint_bin); if ($err) { throw new ArcanistUsageException( "PyLint does not appear to be installed on this system. Install it ". "(e.g., with 'sudo easy_install pylint') or configure ". "'lint.pylint.prefix' in your .arcconfig to point to the directory ". "where it resides."); } } return $pylint_bin; } private function getPyLintPythonPath() { // Get non-default install locations for pylint and its dependencies // libraries. $working_copy = $this->getEngine()->getWorkingCopy(); $prefixes = array( $working_copy->getConfig('lint.pylint.prefix'), $working_copy->getConfig('lint.pylint.logilab_astng.prefix'), $working_copy->getConfig('lint.pylint.logilab_common.prefix'), ); // Add the libraries to the python search path $python_path = array(); foreach ($prefixes as $prefix) { if ($prefix !== null) { $python_path[] = $prefix.'/lib/python2.6/site-packages'; } } $config_paths = $working_copy->getConfig('lint.pylint.pythonpath'); if ($config_paths !== null) { foreach ($config_paths as $config_path) { if ($config_path !== null) { $python_path[] = Filesystem::resolvePath($config_path, $working_copy->getProjectRoot()); } } } $python_path[] = ''; return implode(":", $python_path); } private function getPyLintOptions() { // '-rn': don't print lint report/summary at end // '-iy': show message codes for lint warnings/errors $options = array('-rn', '-iy'); $working_copy = $this->getEngine()->getWorkingCopy(); // Specify an --rcfile, either absolute or relative to the project root. // Stupidly, the command line args above are overridden by rcfile, so be // careful. $rcfile = $working_copy->getConfig('lint.pylint.rcfile'); if ($rcfile !== null) { $rcfile = Filesystem::resolvePath( $rcfile, $working_copy->getProjectRoot()); $options[] = csprintf('--rcfile=%s', $rcfile); } // Add any options defined in the config file for PyLint $config_options = $working_copy->getConfig('lint.pylint.options'); if ($config_options !== null) { $options = array_merge($options, $config_options); } return implode(" ", $options); } public function willLintPaths(array $paths) { return; } public function getLinterName() { return 'PyLint'; } public function getLintSeverityMap() { return array(); } public function getLintNameMap() { return array(); } public function lintPath($path) { $pylint_bin = $this->getPyLintPath(); $python_path = $this->getPyLintPythonPath(); $options = $this->getPyLintOptions(); $path_on_disk = $this->getEngine()->getFilePathOnDisk($path); try { list($stdout, $_) = execx( "/usr/bin/env PYTHONPATH=%s\$PYTHONPATH ". "{$pylint_bin} {$options} {$path_on_disk}", $python_path); } catch (CommandException $e) { if ($e->getError() == 32) { // According to ##man pylint## the exit status of 32 means there was a // usage error. That's bad, so actually exit abnormally. throw $e; } else { // The other non-zero exit codes mean there were messages issued, // which is expected, so don't exit. $stdout = $e->getStdout(); } } $lines = explode("\n", $stdout); $messages = array(); foreach ($lines as $line) { $matches = null; if (!preg_match( '/([A-Z]\d+): *(\d+)(?:|,\d*): *(.*)$/', $line, $matches)) { continue; } foreach ($matches as $key => $match) { $matches[$key] = trim($match); } $message = new ArcanistLintMessage(); $message->setPath($path); $message->setLine($matches[2]); $message->setCode($matches[1]); $message->setName($this->getLinterName()." ".$matches[1]); $message->setDescription($matches[3]); $message->setSeverity($this->getMessageCodeSeverity($matches[1])); $this->addLintMessage($message); } } } diff --git a/src/lint/linter/spelling/ArcanistSpellingLinter.php b/src/lint/linter/spelling/ArcanistSpellingLinter.php index 4560f7ee..5abd8933 100644 --- a/src/lint/linter/spelling/ArcanistSpellingLinter.php +++ b/src/lint/linter/spelling/ArcanistSpellingLinter.php @@ -1,151 +1,151 @@ severity = $severity; $this->wholeWordRules = ArcanistSpellingDefaultData::getFullWordRules(); $this->partialWordRules = ArcanistSpellingDefaultData::getPartialWordRules(); } public function willLintPaths(array $paths) { return; } public function getLinterName() { return 'SPELL'; } public function removeLintRule($word) { foreach ($this->partialWordRules as $severity=>&$wordlist) { unset($wordlist[$word]); } foreach ($this->wholeWordRules as $severity=>&$wordlist) { unset($wordlist[$word]); } } public function addPartialWordRule( $incorrect_word, $correct_word, $severity=self::LINT_SPELLING_IMPORTANT) { $this->partialWordRules[$severity][$incorrect_word] = $correct_word; } public function addWholeWordRule( $incorrect_word, $correct_word, $severity=self::LINT_SPELLING_IMPORTANT) { $this->wholeWordRules[$severity][$incorrect_word] = $correct_word; } public function getLintSeverityMap() { return array( self::LINT_SPELLING_PICKY => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_SPELLING_IMPORTANT => ArcanistLintSeverity::SEVERITY_ERROR, ); } public function getLintNameMap() { return array( self::LINT_SPELLING_PICKY => 'Possible spelling mistake', self::LINT_SPELLING_IMPORTANT => 'Possible spelling mistake', ); } public function lintPath($path) { foreach ($this->partialWordRules as $severity => $wordlist) { if ($severity >= $this->severity) { foreach ($wordlist as $misspell => $correct) { $this->checkPartialWord($path, $misspell, $correct, $severity); } } } foreach ($this->wholeWordRules as $severity => $wordlist) { if ($severity >= $this->severity) { foreach ($wordlist as $misspell => $correct) { $this->checkWholeWord($path, $misspell, $correct, $severity); } } } } protected function checkPartialWord($path, $word, $correct_word, $severity) { $text = $this->getData($path); $pos = 0; while ($pos < strlen($text)) { $next = stripos($text, $word, $pos); if ($next === false) { return; } $this->raiseLintAtOffset( $next, $severity, sprintf( "Possible spelling error. You wrote '%s', but did you mean '%s'", $word, $correct_word ) ); $pos = $next + 1; } } protected function checkWholeWord($path, $word, $correct_word, $severity) { $text = $this->getData($path); $matches = array(); $num_matches = preg_match_all( '#\b' . preg_quote($word, '#') . '\b#i', $text, $matches, PREG_OFFSET_CAPTURE ); if (!$num_matches) { return; } foreach ($matches[0] as $match) { $this->raiseLintAtOffset( $match[1], $severity, sprintf( "Possible spelling error. You wrote '%s', but did you mean '%s'", $word, $correct_word ) ); } } } diff --git a/src/lint/linter/spelling/__tests__/ArcanistSpellingLinterTestCase.php b/src/lint/linter/spelling/__tests__/ArcanistSpellingLinterTestCase.php index cbaa17b9..cdead793 100644 --- a/src/lint/linter/spelling/__tests__/ArcanistSpellingLinterTestCase.php +++ b/src/lint/linter/spelling/__tests__/ArcanistSpellingLinterTestCase.php @@ -1,38 +1,38 @@ removeLintRule('acc'.'out'); $linter->addPartialWordRule('supermn', 'superman'); $linter->addWholeWordRule('batmn', 'batman'); $working_copy = ArcanistWorkingCopyIdentity::newFromPath(__FILE__); return $this->executeTestsInDirectory( dirname(__FILE__).'/data/', $linter, $working_copy); } } diff --git a/src/lint/linter/text/ArcanistTextLinter.php b/src/lint/linter/text/ArcanistTextLinter.php index a3c1df30..8326af26 100644 --- a/src/lint/linter/text/ArcanistTextLinter.php +++ b/src/lint/linter/text/ArcanistTextLinter.php @@ -1,222 +1,222 @@ maxLineLength = $new_length; return $this; } public function willLintPaths(array $paths) { return; } public function getLinterName() { return 'TXT'; } public function getLintSeverityMap() { return array( self::LINT_LINE_WRAP => ArcanistLintSeverity::SEVERITY_WARNING, ); } public function getLintNameMap() { return array( self::LINT_DOS_NEWLINE => 'DOS Newlines', self::LINT_TAB_LITERAL => 'Tab Literal', self::LINT_LINE_WRAP => 'Line Too Long', self::LINT_EOF_NEWLINE => 'File Does Not End in Newline', self::LINT_BAD_CHARSET => 'Bad Charset', self::LINT_TRAILING_WHITESPACE => 'Trailing Whitespace', self::LINT_NO_COMMIT => 'Explicit @no'.'commit', ); } public function lintPath($path) { if (!strlen($this->getData($path))) { // If the file is empty, don't bother; particularly, don't require // the user to add a newline. return; } $this->lintNewlines($path); $this->lintTabs($path); if ($this->didStopAllLinters()) { return; } $this->lintCharset($path); if ($this->didStopAllLinters()) { return; } $this->lintLineLength($path); $this->lintEOFNewline($path); $this->lintTrailingWhitespace($path); if ($this->getEngine()->getCommitHookMode()) { $this->lintNoCommit($path); } } protected function lintNewlines($path) { $pos = strpos($this->getData($path), "\r"); if ($pos !== false) { $this->raiseLintAtOffset( $pos, self::LINT_DOS_NEWLINE, 'You must use ONLY Unix linebreaks ("\n") in source code.', "\r"); if ($this->isMessageEnabled(self::LINT_DOS_NEWLINE)) { $this->stopAllLinters(); } } } protected function lintTabs($path) { $pos = strpos($this->getData($path), "\t"); if ($pos !== false) { $this->raiseLintAtOffset( $pos, self::LINT_TAB_LITERAL, 'Configure your editor to use spaces for indentation.', "\t"); } } protected function lintLineLength($path) { $lines = explode("\n", $this->getData($path)); $width = $this->maxLineLength; foreach ($lines as $line_idx => $line) { if (strlen($line) > $width) { $this->raiseLintAtLine( $line_idx + 1, 1, self::LINT_LINE_WRAP, 'This line is '.number_format(strlen($line)).' characters long, '. 'but the convention is '.$width.' characters.', $line); } } } protected function lintEOFNewline($path) { $data = $this->getData($path); if (!strlen($data) || $data[strlen($data) - 1] != "\n") { $this->raiseLintAtOffset( strlen($data), self::LINT_EOF_NEWLINE, "Files must end in a newline.", '', "\n"); } } protected function lintCharset($path) { $data = $this->getData($path); $matches = null; $preg = preg_match_all( '/[^\x09\x0A\x20-\x7E]+/', $data, $matches, PREG_OFFSET_CAPTURE); if (!$preg) { return; } foreach ($matches[0] as $match) { list($string, $offset) = $match; $this->raiseLintAtOffset( $offset, self::LINT_BAD_CHARSET, 'Source code should contain only ASCII bytes with ordinal decimal '. 'values between 32 and 126 inclusive, plus linefeed. Do not use UTF-8 '. 'or other multibyte charsets.', $string); } if ($this->isMessageEnabled(self::LINT_BAD_CHARSET)) { $this->stopAllLinters(); } } protected function lintTrailingWhitespace($path) { $data = $this->getData($path); $matches = null; $preg = preg_match_all( '/ +$/m', $data, $matches, PREG_OFFSET_CAPTURE); if (!$preg) { return; } foreach ($matches[0] as $match) { list($string, $offset) = $match; $this->raiseLintAtOffset( $offset, self::LINT_TRAILING_WHITESPACE, 'This line contains trailing whitespace.', $string, ''); } } private function lintNoCommit($path) { $data = $this->getData($path); $deadly = '@no'.'commit'; $offset = strpos($data, $deadly); if ($offset !== false) { $this->raiseLintAtOffset( $offset, self::LINT_NO_COMMIT, 'This file is explicitly marked as "'.$deadly.'", which blocks '. 'commits.', $deadly); } } } diff --git a/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php b/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php index a271bcc0..021eff46 100644 --- a/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php +++ b/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php @@ -1,35 +1,35 @@ executeTestsInDirectory( dirname(__FILE__).'/data/', $linter, $working_copy); } } diff --git a/src/lint/linter/xhpast/ArcanistXHPASTLinter.php b/src/lint/linter/xhpast/ArcanistXHPASTLinter.php index 97598bfd..f78cd3ab 100644 --- a/src/lint/linter/xhpast/ArcanistXHPASTLinter.php +++ b/src/lint/linter/xhpast/ArcanistXHPASTLinter.php @@ -1,1462 +1,1462 @@ 'PHP Syntax Error!', self::LINT_UNABLE_TO_PARSE => 'Unable to Parse', self::LINT_VARIABLE_VARIABLE => 'Use of Variable Variable', self::LINT_EXTRACT_USE => 'Use of extract()', self::LINT_UNDECLARED_VARIABLE => 'Use of Undeclared Variable', self::LINT_PHP_SHORT_TAG => 'Use of Short Tag " 'Use of Echo Tag " 'Use of Close Tag "?>"', self::LINT_NAMING_CONVENTIONS => 'Naming Conventions', self::LINT_IMPLICIT_CONSTRUCTOR => 'Implicit Constructor', self::LINT_DYNAMIC_DEFINE => 'Dynamic define()', self::LINT_STATIC_THIS => 'Use of $this in Static Context', self::LINT_PREG_QUOTE_MISUSE => 'Misuse of preg_quote()', self::LINT_PHP_OPEN_TAG => 'Expected Open Tag', self::LINT_TODO_COMMENT => 'TODO Comment', self::LINT_EXIT_EXPRESSION => 'Exit Used as Expression', self::LINT_COMMENT_STYLE => 'Comment Style', self::LINT_CLASS_FILENAME_MISMATCH => 'Class-Filename Mismatch', self::LINT_TAUTOLOGICAL_EXPRESSION => 'Tautological Expression', self::LINT_PLUS_OPERATOR_ON_STRINGS => 'Not String Concatenation', self::LINT_DUPLICATE_KEYS_IN_ARRAY => 'Duplicate Keys in Array', self::LINT_REUSED_ITERATORS => 'Reuse of Iterator Variable', self::LINT_BRACE_FORMATTING => 'Brace placement', self::LINT_PARENTHESES_SPACING => 'Spaces Inside Parentheses', self::LINT_CONTROL_STATEMENT_SPACING => 'Space After Control Statement', self::LINT_BINARY_EXPRESSION_SPACING => 'Space Around Binary Operator', self::LINT_ARRAY_INDEX_SPACING => 'Spacing Before Array Index', self::LINT_RAGGED_CLASSTREE_EDGE => 'Class Not abstract Or final', ); } public function getLinterName() { return 'XHP'; } public function getLintSeverityMap() { return array( self::LINT_TODO_COMMENT => ArcanistLintSeverity::SEVERITY_ADVICE, self::LINT_UNABLE_TO_PARSE => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_NAMING_CONVENTIONS => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_PREG_QUOTE_MISUSE => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_BRACE_FORMATTING => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_PARENTHESES_SPACING => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_CONTROL_STATEMENT_SPACING => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_BINARY_EXPRESSION_SPACING => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_ARRAY_INDEX_SPACING => ArcanistLintSeverity::SEVERITY_WARNING, // This is disabled by default because it implies a very strict policy // which isn't necessary in the general case. self::LINT_RAGGED_CLASSTREE_EDGE => ArcanistLintSeverity::SEVERITY_DISABLED, ); } public function willLintPaths(array $paths) { $futures = array(); foreach ($paths as $path) { $futures[$path] = xhpast_get_parser_future($this->getData($path)); } foreach ($futures as $path => $future) { $this->willLintPath($path); try { $this->trees[$path] = XHPASTTree::newFromDataAndResolvedExecFuture( $this->getData($path), $future->resolve()); } catch (XHPASTSyntaxErrorException $ex) { $this->raiseLintAtLine( $ex->getErrorLine(), 1, self::LINT_PHP_SYNTAX_ERROR, 'This file contains a syntax error: '.$ex->getMessage()); $this->stopAllLinters(); return; } catch (Exception $ex) { $this->raiseLintAtPath( self::LINT_UNABLE_TO_PARSE, 'XHPAST could not parse this file, probably because the AST is too '. 'deep. Some lint issues may not have been detected. You may safely '. 'ignore this warning.'); return; } } } public function getXHPASTTreeForPath($path) { return idx($this->trees, $path); } public function lintPath($path) { if (empty($this->trees[$path])) { return; } $root = $this->trees[$path]->getRootNode(); $this->lintUseOfThisInStaticMethods($root); $this->lintDynamicDefines($root); $this->lintSurpriseConstructors($root); $this->lintPHPTagUse($root); $this->lintVariableVariables($root); $this->lintTODOComments($root); $this->lintExitExpressions($root); $this->lintSpaceAroundBinaryOperators($root); $this->lintSpaceAfterControlStatementKeywords($root); $this->lintParenthesesShouldHugExpressions($root); $this->lintNamingConventions($root); $this->lintPregQuote($root); $this->lintUndeclaredVariables($root); $this->lintArrayIndexWhitespace($root); $this->lintHashComments($root); $this->lintPrimaryDeclarationFilenameMatch($root); $this->lintTautologicalExpressions($root); $this->lintPlusOperatorOnStrings($root); $this->lintDuplicateKeysInArray($root); $this->lintReusedIterators($root); $this->lintBraceFormatting($root); $this->lintRaggedClasstreeEdges($root); } private function lintBraceFormatting($root) { foreach ($root->selectDescendantsOfType('n_STATEMENT_LIST') as $list) { $tokens = $list->getTokens(); if (!$tokens || head($tokens)->getValue() != '{') { continue; } list($before, $after) = $list->getSurroundingNonsemanticTokens(); if (!$before) { $first = head($tokens); // Only insert the space if we're after a closing parenthesis. If // we're in a construct like "else{}", other rules will insert space // after the 'else' correctly. $prev = $first->getPrevToken(); if (!$prev || $prev->getValue() != ')') { continue; } $this->raiseLintAtToken( $first, self::LINT_BRACE_FORMATTING, 'Put opening braces on the same line as control statements and '. 'declarations, with a single space before them.', ' '.$first->getValue()); } else if (count($before) == 1) { $before = reset($before); if ($before->getValue() != ' ') { $this->raiseLintAtToken( $before, self::LINT_BRACE_FORMATTING, 'Put opening braces on the same line as control statements and '. 'declarations, with a single space before them.', ' '); } } } } private function lintTautologicalExpressions($root) { $expressions = $root->selectDescendantsOfType('n_BINARY_EXPRESSION'); static $operators = array( '-' => true, '/' => true, '-=' => true, '/=' => true, '<=' => true, '<' => true, '==' => true, '===' => true, '!=' => true, '!==' => true, '>=' => true, '>' => true, ); static $logical = array( '||' => true, '&&' => true, ); foreach ($expressions as $expr) { $operator = $expr->getChildByIndex(1)->getConcreteString(); if (!empty($operators[$operator])) { $left = $expr->getChildByIndex(0)->getSemanticString(); $right = $expr->getChildByIndex(2)->getSemanticString(); if ($left == $right) { $this->raiseLintAtNode( $expr, self::LINT_TAUTOLOGICAL_EXPRESSION, 'Both sides of this expression are identical, so it always '. 'evaluates to a constant.'); } } if (!empty($logical[$operator])) { $left = $expr->getChildByIndex(0)->getSemanticString(); $right = $expr->getChildByIndex(2)->getSemanticString(); // NOTE: These will be null to indicate "could not evaluate". $left = $this->evaluateStaticBoolean($left); $right = $this->evaluateStaticBoolean($right); if (($operator == '||' && ($left === true || $right === true)) || ($operator == '&&' && ($left === false || $right === false))) { $this->raiseLintAtNode( $expr, self::LINT_TAUTOLOGICAL_EXPRESSION, 'The logical value of this expression is static. Did you forget '. 'to remove some debugging code?'); } } } } /** * Statically evaluate a boolean value from an XHP tree. * * TODO: Improve this and move it to XHPAST proper? * * @param string The "semantic string" of a single value. * @return mixed ##true## or ##false## if the value could be evaluated * statically; ##null## if static evaluation was not possible. */ private function evaluateStaticBoolean($string) { switch (strtolower($string)) { case '0': case 'null': case 'false': return false; case '1': case 'true': return true; } return null; } protected function lintHashComments($root) { $tokens = $root->getTokens(); foreach ($tokens as $token) { if ($token->getTypeName() == 'T_COMMENT') { $value = $token->getValue(); if ($value[0] == '#') { $this->raiseLintAtOffset( $token->getOffset(), self::LINT_COMMENT_STYLE, 'Use "//" single-line comments, not "#".', '#', '//'); } } } } /** * Find cases where loops get nested inside each other but use the same * iterator variable. For example: * * COUNTEREXAMPLE * foreach ($list as $thing) { * foreach ($stuff as $thing) { // <-- Raises an error for reuse of $thing * // ... * } * } * */ private function lintReusedIterators($root) { $used_vars = array(); $for_loops = $root->selectDescendantsOfType('n_FOR'); foreach ($for_loops as $for_loop) { $var_map = array(); // Find all the variables that are assigned to in the for() expression. $for_expr = $for_loop->getChildOfType(0, 'n_FOR_EXPRESSION'); $bin_exprs = $for_expr->selectDescendantsOfType('n_BINARY_EXPRESSION'); foreach ($bin_exprs as $bin_expr) { if ($bin_expr->getChildByIndex(1)->getConcreteString() == '=') { $var_map[$bin_expr->getChildByIndex(0)->getConcreteString()] = true; } } $used_vars[$for_loop->getID()] = $var_map; } $foreach_loops = $root->selectDescendantsOfType('n_FOREACH'); foreach ($foreach_loops as $foreach_loop) { $var_map = array(); $foreach_expr = $foreach_loop->getChildOftype(0, 'n_FOREACH_EXPRESSION'); // We might use one or two vars, i.e. "foreach ($x as $y => $z)" or // "foreach ($x as $y)". $possible_used_vars = array( $foreach_expr->getChildByIndex(1), $foreach_expr->getChildByIndex(2), ); foreach ($possible_used_vars as $var) { if ($var->getTypeName() == 'n_EMPTY') { continue; } $name = $var->getConcreteString(); $name = trim($name, '&'); // Get rid of ref silliness. $var_map[$name] = true; } $used_vars[$foreach_loop->getID()] = $var_map; } $all_loops = $for_loops->add($foreach_loops); foreach ($all_loops as $loop) { $child_for_loops = $loop->selectDescendantsOfType('n_FOR'); $child_foreach_loops = $loop->selectDescendantsOfType('n_FOREACH'); $child_loops = $child_for_loops->add($child_foreach_loops); $outer_vars = $used_vars[$loop->getID()]; foreach ($child_loops as $inner_loop) { $inner_vars = $used_vars[$inner_loop->getID()]; $shared = array_intersect_key($outer_vars, $inner_vars); if ($shared) { $shared_desc = implode(', ', array_keys($shared)); $this->raiseLintAtNode( $inner_loop->getChildByIndex(0), self::LINT_REUSED_ITERATORS, "This loop reuses iterator variables ({$shared_desc}) from an ". "outer loop. You might be clobbering the outer iterator. Change ". "the inner loop to use a different iterator name."); } } } } protected function lintVariableVariables($root) { $vvars = $root->selectDescendantsOfType('n_VARIABLE_VARIABLE'); foreach ($vvars as $vvar) { $this->raiseLintAtNode( $vvar, self::LINT_VARIABLE_VARIABLE, 'Rewrite this code to use an array. Variable variables are unclear '. 'and hinder static analysis.'); } } protected function lintUndeclaredVariables($root) { // These things declare variables in a function: // Explicit parameters // Assignment // Assignment via list() // Static // Global // Lexical vars // Builtins ($this) // foreach() // catch // // These things make lexical scope unknowable: // Use of extract() // Assignment to variable variables ($$x) // Global with variable variables // // These things don't count as "using" a variable: // isset() // empty() // Static class variables // // The general approach here is to find each function/method declaration, // then: // // 1. Identify all the variable declarations, and where they first occur // in the function/method declaration. // 2. Identify all the uses that don't really count (as above). // 3. Everything else must be a use of a variable. // 4. For each variable, check if any uses occur before the declaration // and warn about them. // // We also keep track of where lexical scope becomes unknowable (e.g., // because the function calls extract() or uses dynamic variables, // preventing us from keeping track of which variables are defined) so we // can stop issuing warnings after that. $fdefs = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION'); $mdefs = $root->selectDescendantsOfType('n_METHOD_DECLARATION'); $defs = $fdefs->add($mdefs); foreach ($defs as $def) { // We keep track of the first offset where scope becomes unknowable, and // silence any warnings after that. Default it to INT_MAX so we can min() // it later to keep track of the first problem we encounter. $scope_destroyed_at = PHP_INT_MAX; $declarations = array( '$this' => 0, ) + array_fill_keys($this->getSuperGlobalNames(), 0); $declaration_tokens = array(); $exclude_tokens = array(); $vars = array(); // First up, find all the different kinds of declarations, as explained // above. Put the tokens into the $vars array. $param_list = $def->getChildOfType(3, 'n_DECLARATION_PARAMETER_LIST'); $param_vars = $param_list->selectDescendantsOfType('n_VARIABLE'); foreach ($param_vars as $var) { $vars[] = $var; } // This is PHP5.3 closure syntax: function () use ($x) {}; $lexical_vars = $def ->getChildByIndex(4) ->selectDescendantsOfType('n_VARIABLE'); foreach ($lexical_vars as $var) { $vars[] = $var; } $body = $def->getChildByIndex(5); if ($body->getTypeName() == 'n_EMPTY') { // Abstract method declaration. continue; } $static_vars = $body ->selectDescendantsOfType('n_STATIC_DECLARATION') ->selectDescendantsOfType('n_VARIABLE'); foreach ($static_vars as $var) { $vars[] = $var; } $global_vars = $body ->selectDescendantsOfType('n_GLOBAL_DECLARATION_LIST'); foreach ($global_vars as $var_list) { foreach ($var_list->getChildren() as $var) { if ($var->getTypeName() == 'n_VARIABLE') { $vars[] = $var; } else { // Dynamic global variable, i.e. "global $$x;". $scope_destroyed_at = min($scope_destroyed_at, $var->getOffset()); // An error is raised elsewhere, no need to raise here. } } } $catches = $body ->selectDescendantsOfType('n_CATCH') ->selectDescendantsOfType('n_VARIABLE'); foreach ($catches as $var) { $vars[] = $var; } $foreaches = $body->selectDescendantsOfType('n_FOREACH_EXPRESSION'); foreach ($foreaches as $foreach_expr) { $key_var = $foreach_expr->getChildByIndex(1); if ($key_var->getTypeName() == 'n_VARIABLE') { $vars[] = $key_var; } $value_var = $foreach_expr->getChildByIndex(2); if ($value_var->getTypeName() == 'n_VARIABLE') { $vars[] = $value_var; } else { // The root-level token may be a reference, as in: // foreach ($a as $b => &$c) { ... } // Reach into the n_VARIABLE_REFERENCE node to grab the n_VARIABLE // node. $vars[] = $value_var->getChildOfType(0, 'n_VARIABLE'); } } $binary = $body->selectDescendantsOfType('n_BINARY_EXPRESSION'); foreach ($binary as $expr) { if ($expr->getChildByIndex(1)->getConcreteString() != '=') { continue; } $lval = $expr->getChildByIndex(0); if ($lval->getTypeName() == 'n_VARIABLE') { $vars[] = $lval; } else if ($lval->getTypeName() == 'n_LIST') { // Recursivey grab everything out of list(), since the grammar // permits list() to be nested. Also note that list() is ONLY valid // as an lval assignments, so we could safely lift this out of the // n_BINARY_EXPRESSION branch. $assign_vars = $lval->selectDescendantsOfType('n_VARIABLE'); foreach ($assign_vars as $var) { $vars[] = $var; } } if ($lval->getTypeName() == 'n_VARIABLE_VARIABLE') { $scope_destroyed_at = min($scope_destroyed_at, $lval->getOffset()); // No need to raise here since we raise an error elsewhere. } } $calls = $body->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = strtolower($call->getChildByIndex(0)->getConcreteString()); if ($name == 'empty' || $name == 'isset') { $params = $call ->getChildOfType(1, 'n_CALL_PARAMETER_LIST') ->selectDescendantsOfType('n_VARIABLE'); foreach ($params as $var) { $exclude_tokens[$var->getID()] = true; } continue; } if ($name != 'extract') { continue; } $scope_destroyed_at = min($scope_destroyed_at, $call->getOffset()); $this->raiseLintAtNode( $call, self::LINT_EXTRACT_USE, 'Avoid extract(). It is confusing and hinders static analysis.'); } // Now we have every declaration. Build two maps, one which just keeps // track of which tokens are part of declarations ($declaration_tokens) // and one which has the first offset where a variable is declared // ($declarations). foreach ($vars as $var) { $concrete = $this->getConcreteVariableString($var); $declarations[$concrete] = min( idx($declarations, $concrete, PHP_INT_MAX), $var->getOffset()); $declaration_tokens[$var->getID()] = true; } // Excluded tokens are ones we don't "count" as being uses, described // above. Put them into $exclude_tokens. $class_statics = $body ->selectDescendantsOfType('n_CLASS_STATIC_ACCESS'); $class_static_vars = $class_statics ->selectDescendantsOfType('n_VARIABLE'); foreach ($class_static_vars as $var) { $exclude_tokens[$var->getID()] = true; } // Issue a warning for every variable token, unless it appears in a // declaration, we know about a prior declaration, we have explicitly // exlcuded it, or scope has been made unknowable before it appears. $all_vars = $body->selectDescendantsOfType('n_VARIABLE'); $issued_warnings = array(); foreach ($all_vars as $var) { if (isset($declaration_tokens[$var->getID()])) { // We know this is part of a declaration, so it's fine. continue; } if (isset($exclude_tokens[$var->getID()])) { // We know this is part of isset() or similar, so it's fine. continue; } if ($var->getOffset() >= $scope_destroyed_at) { // This appears after an extract() or $$var so we have no idea // whether it's legitimate or not. We raised a harshly-worded warning // when scope was made unknowable, so just ignore anything we can't // figure out. continue; } $concrete = $this->getConcreteVariableString($var); if ($var->getOffset() >= idx($declarations, $concrete, PHP_INT_MAX)) { // The use appears after the variable is declared, so it's fine. continue; } if (!empty($issued_warnings[$concrete])) { // We've already issued a warning for this variable so we don't need // to issue another one. continue; } $this->raiseLintAtNode( $var, self::LINT_UNDECLARED_VARIABLE, 'Declare variables prior to use (even if you are passing them '. 'as reference parameters). You may have misspelled this '. 'variable name.'); $issued_warnings[$concrete] = true; } } } private function getConcreteVariableString($var) { $concrete = $var->getConcreteString(); // Strip off curly braces as in $obj->{$property}. $concrete = trim($concrete, '{}'); return $concrete; } protected function lintPHPTagUse($root) { $tokens = $root->getTokens(); foreach ($tokens as $token) { if ($token->getTypeName() == 'T_OPEN_TAG') { if (trim($token->getValue()) == 'raiseLintAtToken( $token, self::LINT_PHP_SHORT_TAG, 'Use the full form of the PHP open tag, "getTypeName() == 'T_OPEN_TAG_WITH_ECHO') { $this->raiseLintAtToken( $token, self::LINT_PHP_ECHO_TAG, 'Avoid the PHP echo short form, "getValue())) { $this->raiseLintAtToken( $token, self::LINT_PHP_OPEN_TAG, 'PHP files should start with "getTypeName() == 'T_CLOSE_TAG') { $this->raiseLintAtToken( $token, self::LINT_PHP_CLOSE_TAG, 'Do not use the PHP closing tag, "?>".'); } } } protected function lintNamingConventions($root) { // We're going to build up a list of tuples // and then try to instantiate a hook class which has the opportunity to // override us. $names = array(); $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $name_token = $class->getChildByIndex(1); $name_string = $name_token->getConcreteString(); $names[] = array( 'class', $name_string, $name_token, ArcanistXHPASTLintNamingHook::isUpperCamelCase($name_string) ? null : 'Follow naming conventions: classes should be named using '. 'UpperCamelCase.', ); } $ifaces = $root->selectDescendantsOfType('n_INTERFACE_DECLARATION'); foreach ($ifaces as $iface) { $name_token = $iface->getChildByIndex(1); $name_string = $name_token->getConcreteString(); $names[] = array( 'interface', $name_string, $name_token, ArcanistXHPASTLintNamingHook::isUpperCamelCase($name_string) ? null : 'Follow naming conventions: interfaces should be named using '. 'UpperCamelCase.', ); } $functions = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION'); foreach ($functions as $function) { $name_token = $function->getChildByIndex(2); if ($name_token->getTypeName() == 'n_EMPTY') { // Unnamed closure. continue; } $name_string = $name_token->getConcreteString(); $names[] = array( 'function', $name_string, $name_token, ArcanistXHPASTLintNamingHook::isLowercaseWithUnderscores( ArcanistXHPASTLintNamingHook::stripPHPFunction($name_string)) ? null : 'Follow naming conventions: functions should be named using '. 'lowercase_with_underscores.', ); } $methods = $root->selectDescendantsOfType('n_METHOD_DECLARATION'); foreach ($methods as $method) { $name_token = $method->getChildByIndex(2); $name_string = $name_token->getConcreteString(); $names[] = array( 'method', $name_string, $name_token, ArcanistXHPASTLintNamingHook::isLowerCamelCase( ArcanistXHPASTLintNamingHook::stripPHPFunction($name_string)) ? null : 'Follow naming conventions: methods should be named using '. 'lowerCamelCase.', ); } $param_tokens = array(); $params = $root->selectDescendantsOfType('n_DECLARATION_PARAMETER_LIST'); foreach ($params as $param_list) { foreach ($param_list->getChildren() as $param) { $name_token = $param->getChildByIndex(1); if ($name_token->getTypeName() == 'n_VARIABLE_REFERENCE') { $name_token = $name_token->getChildOfType(0, 'n_VARIABLE'); } $param_tokens[$name_token->getID()] = true; $name_string = $name_token->getConcreteString(); $names[] = array( 'parameter', $name_string, $name_token, ArcanistXHPASTLintNamingHook::isLowercaseWithUnderscores( ArcanistXHPASTLintNamingHook::stripPHPVariable($name_string)) ? null : 'Follow naming conventions: parameters should be named using '. 'lowercase_with_underscores.', ); } } $constants = $root->selectDescendantsOfType( 'n_CLASS_CONSTANT_DECLARATION_LIST'); foreach ($constants as $constant_list) { foreach ($constant_list->getChildren() as $constant) { $name_token = $constant->getChildByIndex(0); $name_string = $name_token->getConcreteString(); $names[] = array( 'constant', $name_string, $name_token, ArcanistXHPASTLintNamingHook::isUppercaseWithUnderscores($name_string) ? null : 'Follow naming conventions: class constants should be named '. 'using UPPERCASE_WITH_UNDERSCORES.', ); } } $member_tokens = array(); $props = $root->selectDescendantsOfType('n_CLASS_MEMBER_DECLARATION_LIST'); foreach ($props as $prop_list) { foreach ($prop_list->getChildren() as $token_id => $prop) { if ($prop->getTypeName() == 'n_CLASS_MEMBER_MODIFIER_LIST') { continue; } $name_token = $prop->getChildByIndex(0); $member_tokens[$name_token->getID()] = true; $name_string = $name_token->getConcreteString(); $names[] = array( 'member', $name_string, $name_token, ArcanistXHPASTLintNamingHook::isLowerCamelCase( ArcanistXHPASTLintNamingHook::stripPHPVariable($name_string)) ? null : 'Follow naming conventions: class properties should be named '. 'using lowerCamelCase.', ); } } $superglobal_map = array_fill_keys( $this->getSuperGlobalNames(), true); $fdefs = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION'); $mdefs = $root->selectDescendantsOfType('n_METHOD_DECLARATION'); $defs = $fdefs->add($mdefs); foreach ($defs as $def) { $globals = $def->selectDescendantsOfType('n_GLOBAL_DECLARATION_LIST'); $globals = $globals->selectDescendantsOfType('n_VARIABLE'); $globals_map = array(); foreach ($globals as $global) { $global_string = $global->getConcreteString(); $globals_map[$global_string] = true; $names[] = array( 'global', $global_string, $global, // No advice for globals, but hooks have an option to provide some. null); } // Exclude access of static properties, since lint will be raised at // their declaration if they're invalid and they may not conform to // variable rules. This is slightly overbroad (includes the entire // rhs of a "Class::..." token) to cover cases like "Class:$x[0]". These // varaibles are simply made exempt from naming conventions. $exclude_tokens = array(); $statics = $def->selectDescendantsOfType('n_CLASS_STATIC_ACCESS'); foreach ($statics as $static) { $rhs = $static->getChildByIndex(1); $rhs_vars = $def->selectDescendantsOfType('n_VARIABLE'); foreach ($rhs_vars as $var) { $exclude_tokens[$var->getID()] = true; } } $vars = $def->selectDescendantsOfType('n_VARIABLE'); foreach ($vars as $token_id => $var) { if (isset($member_tokens[$token_id])) { continue; } if (isset($param_tokens[$token_id])) { continue; } if (isset($exclude_tokens[$token_id])) { continue; } $var_string = $var->getConcreteString(); // Awkward artifact of "$o->{$x}". $var_string = trim($var_string, '{}'); if (isset($superglobal_map[$var_string])) { continue; } if (isset($globals_map[$var_string])) { continue; } $names[] = array( 'variable', $var_string, $var, ArcanistXHPASTLintNamingHook::isLowercaseWithUnderscores( ArcanistXHPASTLintNamingHook::stripPHPVariable($var_string)) ? null : 'Follow naming conventions: variables should be named using '. 'lowercase_with_underscores.', ); } } $engine = $this->getEngine(); $working_copy = $engine->getWorkingCopy(); if ($working_copy) { // If a naming hook is configured, give it a chance to override the // default results for all the symbol names. $hook_class = $working_copy->getConfig('lint.xhpast.naminghook'); if ($hook_class) { $hook_obj = newv($hook_class, array()); foreach ($names as $k => $name_attrs) { list($type, $name, $token, $default) = $name_attrs; $result = $hook_obj->lintSymbolName($type, $name, $default); $names[$k][3] = $result; } } } // Raise anything we're left with. foreach ($names as $k => $name_attrs) { list($type, $name, $token, $result) = $name_attrs; if ($result) { $this->raiseLintAtNode( $token, self::LINT_NAMING_CONVENTIONS, $result); } } } protected function lintSurpriseConstructors($root) { $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $class_name = $class->getChildByIndex(1)->getConcreteString(); $methods = $class->selectDescendantsOfType('n_METHOD_DECLARATION'); foreach ($methods as $method) { $method_name_token = $method->getChildByIndex(2); $method_name = $method_name_token->getConcreteString(); if (strtolower($class_name) == strtolower($method_name)) { $this->raiseLintAtNode( $method_name_token, self::LINT_IMPLICIT_CONSTRUCTOR, 'Name constructors __construct() explicitly. This method is a '. 'constructor because it has the same name as the class it is '. 'defined in.'); } } } } protected function lintParenthesesShouldHugExpressions($root) { $calls = $root->selectDescendantsOfType('n_CALL_PARAMETER_LIST'); $controls = $root->selectDescendantsOfType('n_CONTROL_CONDITION'); $fors = $root->selectDescendantsOfType('n_FOR_EXPRESSION'); $foreach = $root->selectDescendantsOfType('n_FOREACH_EXPRESSION'); $decl = $root->selectDescendantsOfType('n_DECLARATION_PARAMETER_LIST'); $all_paren_groups = $calls ->add($controls) ->add($fors) ->add($foreach) ->add($decl); foreach ($all_paren_groups as $group) { $tokens = $group->getTokens(); $token_o = array_shift($tokens); $token_c = array_pop($tokens); if ($token_o->getTypeName() != '(') { throw new Exception('Expected open paren!'); } if ($token_c->getTypeName() != ')') { throw new Exception('Expected close paren!'); } $nonsem_o = $token_o->getNonsemanticTokensAfter(); $nonsem_c = $token_c->getNonsemanticTokensBefore(); if (!$nonsem_o) { continue; } $raise = array(); $string_o = implode('', mpull($nonsem_o, 'getValue')); if (preg_match('/^[ ]+$/', $string_o)) { $raise[] = array($nonsem_o, $string_o); } if ($nonsem_o !== $nonsem_c) { $string_c = implode('', mpull($nonsem_c, 'getValue')); if (preg_match('/^[ ]+$/', $string_c)) { $raise[] = array($nonsem_c, $string_c); } } foreach ($raise as $warning) { list($tokens, $string) = $warning; $this->raiseLintAtOffset( reset($tokens)->getOffset(), self::LINT_PARENTHESES_SPACING, 'Parentheses should hug their contents.', $string, ''); } } } protected function lintSpaceAfterControlStatementKeywords($root) { foreach ($root->getTokens() as $id => $token) { switch ($token->getTypeName()) { case 'T_IF': case 'T_ELSE': case 'T_FOR': case 'T_FOREACH': case 'T_WHILE': case 'T_DO': case 'T_SWITCH': $after = $token->getNonsemanticTokensAfter(); if (empty($after)) { $this->raiseLintAtToken( $token, self::LINT_CONTROL_STATEMENT_SPACING, 'Convention: put a space after control statements.', $token->getValue().' '); } else if (count($after) == 1) { $space = head($after); // If we have an else clause with braces, $space may not be // a single white space. e.g., // // if ($x) // echo 'foo' // else // <- $space is not " " but "\n ". // echo 'bar' // // We just require it starts with either a whitespace or a newline. if ($token->getTypeName() == 'T_ELSE' || $token->getTypeName() == 'T_DO') { break; } if ($space->isAnyWhitespace() && $space->getValue() != ' ') { $this->raiseLintAtToken( $space, self::LINT_CONTROL_STATEMENT_SPACING, 'Convention: put a single space after control statements.', ' '); } } break; } } } protected function lintSpaceAroundBinaryOperators($root) { $expressions = $root->selectDescendantsOfType('n_BINARY_EXPRESSION'); foreach ($expressions as $expression) { $operator = $expression->getChildByIndex(1); $operator_value = $operator->getConcreteString(); if ($operator_value == '.') { // TODO: implement this check continue; } else { list($before, $after) = $operator->getSurroundingNonsemanticTokens(); $replace = null; if (empty($before) && empty($after)) { $replace = " {$operator_value} "; } else if (empty($before)) { $replace = " {$operator_value}"; } else if (empty($after)) { $replace = "{$operator_value} "; } if ($replace !== null) { $this->raiseLintAtNode( $operator, self::LINT_BINARY_EXPRESSION_SPACING, 'Convention: logical and arithmetic operators should be '. 'surrounded by whitespace.', $replace); } } } } protected function lintDynamicDefines($root) { $calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = $call->getChildByIndex(0)->getConcreteString(); if (strtolower($name) == 'define') { $parameter_list = $call->getChildOfType(1, 'n_CALL_PARAMETER_LIST'); $defined = $parameter_list->getChildByIndex(0); if (!$defined->isStaticScalar()) { $this->raiseLintAtNode( $defined, self::LINT_DYNAMIC_DEFINE, 'First argument to define() must be a string literal.'); } } } } protected function lintUseOfThisInStaticMethods($root) { $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $methods = $class->selectDescendantsOfType('n_METHOD_DECLARATION'); foreach ($methods as $method) { $attributes = $method ->getChildByIndex(0, 'n_METHOD_MODIFIER_LIST') ->selectDescendantsOfType('n_STRING'); $method_is_static = false; $method_is_abstract = false; foreach ($attributes as $attribute) { if (strtolower($attribute->getConcreteString()) == 'static') { $method_is_static = true; } if (strtolower($attribute->getConcreteString()) == 'abstract') { $method_is_abstract = true; } } if ($method_is_abstract) { continue; } if (!$method_is_static) { continue; } $body = $method->getChildOfType(5, 'n_STATEMENT_LIST'); $variables = $body->selectDescendantsOfType('n_VARIABLE'); foreach ($variables as $variable) { if ($method_is_static && strtolower($variable->getConcreteString()) == '$this') { $this->raiseLintAtNode( $variable, self::LINT_STATIC_THIS, 'You can not reference "$this" inside a static method.'); } } } } } /** * preg_quote() takes two arguments, but the second one is optional because * it is possible to use (), [] or {} as regular expression delimiters. If * you don't pass a second argument, you're probably going to get something * wrong. */ protected function lintPregQuote($root) { $function_calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($function_calls as $call) { $name = $call->getChildByIndex(0)->getConcreteString(); if (strtolower($name) === 'preg_quote') { $parameter_list = $call->getChildOfType(1, 'n_CALL_PARAMETER_LIST'); if (count($parameter_list->getChildren()) !== 2) { $this->raiseLintAtNode( $call, self::LINT_PREG_QUOTE_MISUSE, 'You should always pass two arguments to preg_quote(), so that ' . 'preg_quote() knows which delimiter to escape.'); } } } } /** * Exit is parsed as an expression, but using it as such is almost always * wrong. That is, this is valid: * * strtoupper(33 * exit - 6); * * When exit is used as an expression, it causes the program to terminate with * exit code 0. This is likely not what is intended; these statements have * different effects: * * exit(-1); * exit -1; * * The former exits with a failure code, the latter with a success code! */ protected function lintExitExpressions($root) { $unaries = $root->selectDescendantsOfType('n_UNARY_PREFIX_EXPRESSION'); foreach ($unaries as $unary) { $operator = $unary->getChildByIndex(0)->getConcreteString(); if (strtolower($operator) == 'exit') { if ($unary->getParentNode()->getTypeName() != 'n_STATEMENT') { $this->raiseLintAtNode( $unary, self::LINT_EXIT_EXPRESSION, "Use exit as a statement, not an expression."); } } } } private function lintArrayIndexWhitespace($root) { $indexes = $root->selectDescendantsOfType('n_INDEX_ACCESS'); foreach ($indexes as $index) { $tokens = $index->getChildByIndex(0)->getTokens(); $last = array_pop($tokens); $trailing = $last->getNonsemanticTokensAfter(); $trailing_text = implode('', mpull($trailing, 'getValue')); if (preg_match('/^ +$/', $trailing_text)) { $this->raiseLintAtOffset( $last->getOffset() + strlen($last->getValue()), self::LINT_ARRAY_INDEX_SPACING, 'Convention: no spaces before index access.', $trailing_text, ''); } } } protected function lintTODOComments($root) { $tokens = $root->getTokens(); foreach ($tokens as $token) { if (!$token->isComment()) { continue; } $value = $token->getValue(); $matches = null; $preg = preg_match_all( '/TODO/', $value, $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { list($string, $offset) = $match; $this->raiseLintAtOffset( $token->getOffset() + $offset, self::LINT_TODO_COMMENT, 'This comment has a TODO.', $string); } } } /** * Lint that if the file declares exactly one interface or class, * the name of the file matches the name of the class, * unless the classname is funky like an XHP element. */ private function lintPrimaryDeclarationFilenameMatch($root) { $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); $interfaces = $root->selectDescendantsOfType('n_INTERFACE_DECLARATION'); if (count($classes) + count($interfaces) != 1) { return; } $declarations = count($classes) ? $classes : $interfaces; $declarations->rewind(); $declaration = $declarations->current(); $decl_name = $declaration->getChildByIndex(1); $decl_string = $decl_name->getConcreteString(); // Exclude strangely named classes, e.g. XHP tags. if (!preg_match('/^\w+$/', $decl_string)) { return; } $rename = $decl_string.'.php'; $path = $this->getActivePath(); $filename = basename($path); if ($rename == $filename) { return; } $this->raiseLintAtNode( $decl_name, self::LINT_CLASS_FILENAME_MISMATCH, "The name of this file differs from the name of the class or interface ". "it declares. Rename the file to '{$rename}'." ); } private function lintPlusOperatorOnStrings($root) { $binops = $root->selectDescendantsOfType('n_BINARY_EXPRESSION'); foreach ($binops as $binop) { $op = $binop->getChildByIndex(1); if ($op->getConcreteString() != '+') { continue; } $left = $binop->getChildByIndex(0); $right = $binop->getChildByIndex(2); if (($left->getTypeName() == 'n_STRING_SCALAR') || ($right->getTypeName() == 'n_STRING_SCALAR')) { $this->raiseLintAtNode( $binop, self::LINT_PLUS_OPERATOR_ON_STRINGS, "In PHP, '.' is the string concatenation operator, not '+'. This ". "expression uses '+' with a string literal as an operand."); } } } /** * Finds duplicate keys in array initializers, as in * array(1 => 'anything', 1 => 'foo'). Since the first entry is ignored, * this is almost certainly an error. */ private function lintDuplicateKeysInArray($root) { $array_literals = $root->selectDescendantsOfType('n_ARRAY_LITERAL'); foreach ($array_literals as $array_literal) { $nodes_by_key = array(); $keys_warn = array(); $list_node = $array_literal->getChildByIndex(0); foreach ($list_node->getChildren() as $array_entry) { $key_node = $array_entry->getChildByIndex(0); switch ($key_node->getTypeName()) { case 'n_STRING_SCALAR': case 'n_NUMERIC_SCALAR': // Scalars: array(1 => 'v1', '1' => 'v2'); $key = 'scalar:'.(string)$key_node->evalStatic(); break; case 'n_SYMBOL_NAME': case 'n_VARIABLE': case 'n_CLASS_STATIC_ACCESS': // Constants: array(CONST => 'v1', CONST => 'v2'); // Variables: array($a => 'v1', $a => 'v2'); // Class constants and vars: array(C::A => 'v1', C::A => 'v2'); $key = $key_node->getTypeName().':'.$key_node->getConcreteString(); break; default: $key = null; } if ($key !== null) { if (isset($nodes_by_key[$key])) { $keys_warn[$key] = true; } $nodes_by_key[$key][] = $key_node; } } foreach ($keys_warn as $key => $_) { foreach ($nodes_by_key[$key] as $node) { $this->raiseLintAtNode( $node, self::LINT_DUPLICATE_KEYS_IN_ARRAY, "Duplicate key in array initializer. PHP will ignore all ". "but the last entry."); } } } } private function lintRaggedClasstreeEdges($root) { $parser = new PhutilDocblockParser(); $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $is_final = false; $is_abstract = false; $is_concrete_extensible = false; $attributes = $class->getChildOfType(0, 'n_CLASS_ATTRIBUTES'); foreach ($attributes->getChildren() as $child) { if ($child->getConcreteString() == 'final') { $is_final = true; } if ($child->getConcreteString() == 'abstract') { $is_abstract = true; } } $docblock = $class->getDocblockToken(); if ($docblock) { list($text, $specials) = $parser->parse($docblock->getValue()); $is_concrete_extensible = idx($specials, 'concrete-extensible'); } if (!$is_final && !$is_abstract && !$is_concrete_extensible) { $this->raiseLintAtNode( $class->getChildOfType(1, 'n_CLASS_NAME'), self::LINT_RAGGED_CLASSTREE_EDGE, "This class is neither 'final' nor 'abstract', and does not have ". "a docblock marking it '@concrete-extensible'."); } } } protected function raiseLintAtToken( XHPASTToken $token, $code, $desc, $replace = null) { return $this->raiseLintAtOffset( $token->getOffset(), $code, $desc, $token->getValue(), $replace); } protected function raiseLintAtNode( XHPASTNode $node, $code, $desc, $replace = null) { return $this->raiseLintAtOffset( $node->getOffset(), $code, $desc, $node->getConcreteString(), $replace); } public function getSuperGlobalNames() { return array( '$GLOBALS', '$_SERVER', '$_GET', '$_POST', '$_FILES', '$_COOKIE', '$_SESSION', '$_REQUEST', '$_ENV', ); } } diff --git a/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php b/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php index b18aafd4..6228aee3 100644 --- a/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php +++ b/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php @@ -1,42 +1,42 @@ setCustomSeverityMap( array( ArcanistXHPASTLinter::LINT_RAGGED_CLASSTREE_EDGE => ArcanistLintSeverity::SEVERITY_WARNING, )); $working_copy = ArcanistWorkingCopyIdentity::newFromPath(__FILE__); return $this->executeTestsInDirectory( dirname(__FILE__).'/data/', $linter, $working_copy); } } diff --git a/src/lint/message/ArcanistLintMessage.php b/src/lint/message/ArcanistLintMessage.php index dcfa2418..8fe9850e 100644 --- a/src/lint/message/ArcanistLintMessage.php +++ b/src/lint/message/ArcanistLintMessage.php @@ -1,195 +1,195 @@ setPath($dict['path']); $message->setLine($dict['line']); $message->setChar($dict['char']); $message->setCode($dict['code']); $message->setSeverity($dict['severity']); $message->setName($dict['name']); $message->setDescription($dict['description']); if (isset($dict['original'])) { $message->setOriginalText($dict['original']); } if (isset($dict['replacement'])) { $message->setReplacementText($dict['replacement']); } return $message; } public function setPath($path) { $this->path = $path; return $this; } public function getPath() { return $this->path; } public function setLine($line) { $this->line = $line; return $this; } public function getLine() { return $this->line; } public function setChar($char) { $this->char = $char; return $this; } public function getChar() { return $this->char; } public function setCode($code) { $this->code = $code; return $this; } public function getCode() { return $this->code; } public function setSeverity($severity) { $this->severity = $severity; return $this; } public function getSeverity() { return $this->severity; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setDescription($description) { $this->description = $description; return $this; } public function getDescription() { return $this->description; } public function setOriginalText($original) { $this->originalText = $original; return $this; } public function getOriginalText() { return $this->originalText; } public function setReplacementText($replacement) { $this->replacementText = $replacement; return $this; } public function getReplacementText() { return $this->replacementText; } public function isError() { return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_ERROR; } public function isWarning() { return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_WARNING; } public function hasFileContext() { return ($this->getLine() !== null); } public function setGenerateFile($generate_file) { $this->generateFile = $generate_file; return $this; } public function getGenerateFile() { return $this->generateFile; } public function setObsolete($obsolete) { $this->obsolete = $obsolete; return $this; } public function getObsolete() { return $this->obsolete; } public function isPatchable() { return ($this->getReplacementText() !== null) && ($this->getReplacementText() !== $this->getOriginalText()); } public function didApplyPatch() { if ($this->appliedToDisk) { return; } $this->appliedToDisk = true; foreach ($this->dependentMessages as $message) { $message->didApplyPatch(); } return $this; } public function isPatchApplied() { return $this->appliedToDisk; } public function setDependentMessages(array $messages) { $this->dependentMessages = $messages; return $this; } } diff --git a/src/lint/renderer/ArcanistLintJSONRenderer.php b/src/lint/renderer/ArcanistLintJSONRenderer.php index d5bed9a6..5cde7cce 100644 --- a/src/lint/renderer/ArcanistLintJSONRenderer.php +++ b/src/lint/renderer/ArcanistLintJSONRenderer.php @@ -1,57 +1,57 @@ getMessages(); $path = $result->getPath(); $data = explode("\n", $result->getData()); array_unshift($data, ''); // make the line numbers work as array indices $output = array($path => array()); foreach ($messages as $message) { $output[$path][] = array( 'code' => $message->getCode(), 'name' => $message->getName(), 'severity' => $message->getSeverity(), 'line' => $message->getLine(), 'char' => $message->getChar(), 'context' => implode("\n", array_slice( $data, $message->getLine() - self::LINES_OF_CONTEXT, self::LINES_OF_CONTEXT * 2 + 1 )), 'description' => $message->getDescription(), ); } return json_encode($output)."\n"; } public function renderOkayResult() { return "\n"; } } diff --git a/src/lint/renderer/ArcanistLintRenderer.php b/src/lint/renderer/ArcanistLintRenderer.php index 235e1612..cdc416e2 100644 --- a/src/lint/renderer/ArcanistLintRenderer.php +++ b/src/lint/renderer/ArcanistLintRenderer.php @@ -1,205 +1,205 @@ getMessages(); $path = $result->getPath(); $lines = explode("\n", $result->getData()); $text = array(); $text[] = phutil_console_format('**>>>** Lint for __%s__:', $path); $text[] = null; foreach ($messages as $message) { if ($message->isError()) { $color = 'red'; } else { $color = 'yellow'; } $severity = ArcanistLintSeverity::getStringForSeverity( $message->getSeverity()); $code = $message->getCode(); $name = $message->getName(); $description = phutil_console_wrap($message->getDescription(), 4); $text[] = phutil_console_format( " ** %s ** (%s) __%s__\n". " %s\n", $severity, $code, $name, $description); if ($message->hasFileContext()) { $text[] = $this->renderContext($message, $lines); } } $text[] = null; $text[] = null; return implode("\n", $text); } protected function renderContext( ArcanistLintMessage $message, array $line_data) { $lines_of_context = 3; $out = array(); $num_lines = count($line_data); // make line numbers line up with array indexes array_unshift($line_data, ''); $line_num = min($message->getLine(), $num_lines); $line_num = max(1, $line_num); // Print out preceding context before the impacted region. $cursor = max(1, $line_num - $lines_of_context); for (; $cursor < $line_num; $cursor++) { $out[] = $this->renderLine($cursor, $line_data[$cursor]); } $text = $message->getOriginalText(); // Refine original and replacement text to eliminate start and end in common if ($message->isPatchable()) { $start = $message->getChar() - 1; $patch = $message->getReplacementText(); $text_strlen = strlen($text); $patch_strlen = strlen($patch); $min_length = min($text_strlen, $patch_strlen); $same_at_front = 0; for ($ii = 0; $ii < $min_length; $ii++) { if ($text[$ii] !== $patch[$ii]) { break; } $same_at_front++; $start++; if ($text[$ii] == "\n") { $out[] = $this->renderLine($cursor, $line_data[$cursor]); $cursor++; $start = 0; $line_num++; } } // deal with shorter string ' ' longer string ' a ' $min_length -= $same_at_front; // And check the end of the string $same_at_end = 0; for ($ii = 1; $ii <= $min_length; $ii++) { if ($text[$text_strlen - $ii] !== $patch[$patch_strlen - $ii]) { break; } $same_at_end++; } $text = substr( $text, $same_at_front, $text_strlen - $same_at_end - $same_at_front ); $patch = substr( $patch, $same_at_front, $patch_strlen - $same_at_end - $same_at_front ); } // Print out the impacted region itself. $diff = $message->isPatchable() ? '-' : null; $text_lines = explode("\n", $text); $text_length = count($text_lines); for (; $cursor < $line_num + $text_length; $cursor++) { $chevron = ($cursor == $line_num); // We may not have any data if, e.g., the old file does not exist. $data = idx($line_data, $cursor, null); // Highlight the problem substring. $text_line = $text_lines[$cursor - $line_num]; if (strlen($text_line)) { $data = substr_replace( $data, phutil_console_format('##%s##', $text_line), ($cursor == $line_num) ? $message->getChar() - 1 : 0, strlen($text_line)); } $out[] = $this->renderLine($cursor, $data, $chevron, $diff); } // Print out replacement text. if ($message->isPatchable()) { $patch_lines = explode("\n", $patch); $patch_length = count($patch_lines); $patch_line = $patch_lines[0]; $len = isset($text_lines[0]) ? strlen($text_lines[0]) : 0; $patched = substr_replace( $line_data[$line_num], phutil_console_format('##%s##', $patch_line), $start, $len); $out[] = $this->renderLine(null, $patched, false, '+'); foreach (array_slice($patch_lines, 1) as $patch_line) { $out[] = $this->renderLine( null, phutil_console_format('##%s##', $patch_line), false, '+' ); } } $end = min($num_lines, $cursor + $lines_of_context); for (; $cursor < $end; $cursor++) { $out[] = $this->renderLine($cursor, $line_data[$cursor]); } $out[] = null; return implode("\n", $out); } protected function renderLine($line, $data, $chevron = false, $diff = null) { $chevron = $chevron ? '>>>' : ''; return sprintf( " %3s %1s %6s %s", $chevron, $diff, $line, $data); } public function renderOkayResult() { return phutil_console_format("** OKAY ** No lint warnings.\n"); } } diff --git a/src/lint/renderer/ArcanistLintSummaryRenderer.php b/src/lint/renderer/ArcanistLintSummaryRenderer.php index b5b22477..a9d05375 100644 --- a/src/lint/renderer/ArcanistLintSummaryRenderer.php +++ b/src/lint/renderer/ArcanistLintSummaryRenderer.php @@ -1,48 +1,48 @@ getMessages(); $path = $result->getPath(); $text = array(); $text[] = $path.":"; foreach ($messages as $message) { $name = $message->getName(); $severity = ArcanistLintSeverity::getStringForSeverity( $message->getSeverity()); $line = $message->getLine(); $text[] = " {$severity} on line {$line}: {$name}"; } $text[] = null; return implode("\n", $text); } public function renderOkayResult() { return phutil_console_format("** OKAY ** No lint warnings.\n"); } } diff --git a/src/lint/severity/ArcanistLintSeverity.php b/src/lint/severity/ArcanistLintSeverity.php index 62b11591..f4d3191b 100644 --- a/src/lint/severity/ArcanistLintSeverity.php +++ b/src/lint/severity/ArcanistLintSeverity.php @@ -1,66 +1,66 @@ 'Advice', self::SEVERITY_WARNING => 'Warning', self::SEVERITY_ERROR => 'Error', self::SEVERITY_DISABLED => 'Disabled', ); if (!array_key_exists($severity_code, $map)) { throw new Exception("Unknown lint severity '{$severity_code}'!"); } return $map[$severity_code]; } public static function isAtLeastAsSevere( ArcanistLintMessage $message, $level) { static $map = array( self::SEVERITY_DISABLED => 10, self::SEVERITY_ADVICE => 20, self::SEVERITY_WARNING => 30, self::SEVERITY_ERROR => 40, ); $message_sev = $message->getSeverity(); if (empty($map[$message_sev])) { return true; } return $map[$message_sev] >= idx($map, $level, 0); } } diff --git a/src/parser/bundle/ArcanistBundle.php b/src/parser/bundle/ArcanistBundle.php index 5acc8603..f7be40d2 100644 --- a/src/parser/bundle/ArcanistBundle.php +++ b/src/parser/bundle/ArcanistBundle.php @@ -1,653 +1,653 @@ conduit = $conduit; } public function setProjectID($project_id) { $this->projectID = $project_id; } public function getProjectID() { return $this->projectID; } public function setBaseRevision($base_revision) { $this->baseRevision = $base_revision; } public function getBaseRevision() { return $this->baseRevision; } public function setRevisionID($revision_id) { $this->revisionID = $revision_id; return $this; } public function getRevisionID() { return $this->revisionID; } public static function newFromChanges(array $changes) { $obj = new ArcanistBundle(); $obj->changes = $changes; return $obj; } public static function newFromArcBundle($path) { $path = Filesystem::resolvePath($path); $future = new ExecFuture( csprintf( 'tar tfO %s', $path)); list($stdout, $file_list) = $future->resolvex(); $file_list = explode("\n", trim($file_list)); if (in_array('meta.json', $file_list)) { $future = new ExecFuture( csprintf( 'tar xfO %s meta.json', $path)); $meta_info = $future->resolveJSON(); $version = idx($meta_info, 'version', 0); $project_name = idx($meta_info, 'projectName'); $base_revision = idx($meta_info, 'baseRevision'); $revision_id = idx($meta_info, 'revisionID'); // this arc bundle was probably made before we started storing meta info } else { $version = 0; $project_name = null; $base_revision = null; $revision_id = null; } $future = new ExecFuture( csprintf( 'tar xfO %s changes.json', $path)); $changes = $future->resolveJSON(); foreach ($changes as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { list($hunk_data) = execx('tar xfO %s hunks/%s', $path, $hunk['corpus']); $changes[$change_key]['hunks'][$key]['corpus'] = $hunk_data; } } foreach ($changes as $change_key => $change) { $changes[$change_key] = ArcanistDiffChange::newFromDictionary($change); } $obj = new ArcanistBundle(); $obj->changes = $changes; $obj->diskPath = $path; $obj->setProjectID($project_name); $obj->setBaseRevision($base_revision); $obj->setRevisionID($revision_id); return $obj; } public static function newFromDiff($data) { $obj = new ArcanistBundle(); $parser = new ArcanistDiffParser(); $obj->changes = $parser->parseDiff($data); return $obj; } private function __construct() { } public function writeToDisk($path) { $changes = $this->getChanges(); $change_list = array(); foreach ($changes as $change) { $change_list[] = $change->toDictionary(); } $hunks = array(); foreach ($change_list as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { $hunks[] = $hunk['corpus']; $change_list[$change_key]['hunks'][$key]['corpus'] = count($hunks) - 1; } } $blobs = array(); foreach ($change_list as $change) { if (!empty($change['metadata']['old:binary-phid'])) { $blobs[$change['metadata']['old:binary-phid']] = null; } if (!empty($change['metadata']['new:binary-phid'])) { $blobs[$change['metadata']['new:binary-phid']] = null; } } foreach ($blobs as $phid => $null) { $blobs[$phid] = $this->getBlob($phid); } $meta_info = array( 'version' => 3, 'projectName' => $this->getProjectID(), 'baseRevision' => $this->getBaseRevision(), 'revisionID' => $this->getRevisionID(), ); $dir = Filesystem::createTemporaryDirectory(); Filesystem::createDirectory($dir.'/hunks'); Filesystem::createDirectory($dir.'/blobs'); Filesystem::writeFile($dir.'/changes.json', json_encode($change_list)); Filesystem::writeFile($dir.'/meta.json', json_encode($meta_info)); foreach ($hunks as $key => $hunk) { Filesystem::writeFile($dir.'/hunks/'.$key, $hunk); } foreach ($blobs as $key => $blob) { Filesystem::writeFile($dir.'/blobs/'.$key, $blob); } execx( '(cd %s; tar -czf %s *)', $dir, Filesystem::resolvePath($path)); Filesystem::remove($dir); } public function toUnifiedDiff() { $result = array(); $changes = $this->getChanges(); foreach ($changes as $change) { $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); $index_path = $cur_path; if ($index_path === null) { $index_path = $old_path; } $result[] = 'Index: '.$index_path; $result[] = str_repeat('=', 67); if ($old_path === null) { $old_path = '/dev/null'; } if ($cur_path === null) { $cur_path = '/dev/null'; } // When the diff is used by `patch`, `patch` ignores what is listed as the // current path and just makes changes to the file at the old path (unless // the current path is '/dev/null'. // If the old path and the current path aren't the same (and neither is // /dev/null), this indicates the file was moved or copied. By listing // both paths as the new file, `patch` will apply the diff to the new // file. if ($cur_path !== '/dev/null' && $old_path !== '/dev/null') { $old_path = $cur_path; } $result[] = '--- '.$old_path; $result[] = '+++ '.$cur_path; $result[] = $this->buildHunkChanges($change->getHunks()); } return implode("\n", $result)."\n"; } public function toGitPatch() { $result = array(); $changes = $this->getChanges(); foreach (array_keys($changes) as $multicopy_key) { $multicopy_change = $changes[$multicopy_key]; $type = $multicopy_change->getType(); if ($type != ArcanistDiffChangeType::TYPE_MULTICOPY) { continue; } // Decompose MULTICOPY into one MOVE_HERE and several COPY_HERE because // we need more information than we have in order to build a delete patch // and represent it as a bunch of COPY_HERE plus a delete. For details, // see T419. // Basically, MULTICOPY means there are 2 or more corresponding COPY_HERE // changes, so find one of them arbitrariy and turn it into a MOVE_HERE. // TODO: We might be able to do this more cleanly after T230 is resolved. $decompose_okay = false; foreach ($changes as $change_key => $change) { if ($change->getType() != ArcanistDiffChangeType::TYPE_COPY_HERE) { continue; } if ($change->getOldPath() != $multicopy_change->getCurrentPath()) { continue; } $decompose_okay = true; $change = clone $change; $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $changes[$change_key] = $change; // The multicopy is now fully represented by MOVE_HERE plus one or more // COPY_HERE, so throw it away. unset($changes[$multicopy_key]); break; } if (!$decompose_okay) { throw new Exception( "Failed to decompose multicopy changeset in order to generate diff."); } } foreach ($changes as $change) { $type = $change->getType(); $file_type = $change->getFileType(); if ($file_type == ArcanistDiffChangeType::FILE_DIRECTORY) { // TODO: We should raise a FYI about this, so the user is aware // that we omitted it, if the directory is empty or has permissions // which git can't represent. // Git doesn't support empty directories, so we simply ignore them. If // the directory is nonempty, 'git apply' will create it when processing // the changesets for files inside it. continue; } if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { // Git will apply this in the corresponding MOVE_HERE. continue; } $old_mode = idx($change->getOldProperties(), 'unix:filemode', '100644'); $new_mode = idx($change->getNewProperties(), 'unix:filemode', '100644'); $is_binary = ($file_type == ArcanistDiffChangeType::FILE_BINARY || $file_type == ArcanistDiffChangeType::FILE_IMAGE); if ($is_binary) { $change_body = $this->buildBinaryChange($change); } else { $change_body = $this->buildHunkChanges($change->getHunks()); } if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { // TODO: This is only relevant when patching old Differential diffs // which were created prior to arc pruning TYPE_COPY_AWAY for files // with no modifications. if (!strlen($change_body) && ($old_mode == $new_mode)) { continue; } } $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); if ($old_path === null) { $old_index = 'a/'.$cur_path; $old_target = '/dev/null'; } else { $old_index = 'a/'.$old_path; $old_target = 'a/'.$old_path; } if ($cur_path === null) { $cur_index = 'b/'.$old_path; $cur_target = '/dev/null'; } else { $cur_index = 'b/'.$cur_path; $cur_target = 'b/'.$cur_path; } $result[] = "diff --git {$old_index} {$cur_index}"; if ($type == ArcanistDiffChangeType::TYPE_ADD) { $result[] = "new file mode {$new_mode}"; } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE || $type == ArcanistDiffChangeType::TYPE_MOVE_HERE || $type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { if ($old_mode !== $new_mode) { $result[] = "old mode {$old_mode}"; $result[] = "new mode {$new_mode}"; } } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE) { $result[] = "copy from {$old_path}"; $result[] = "copy to {$cur_path}"; } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_HERE) { $result[] = "rename from {$old_path}"; $result[] = "rename to {$cur_path}"; } else if ($type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $old_mode = idx($change->getOldProperties(), 'unix:filemode'); if ($old_mode) { $result[] = "deleted file mode {$old_mode}"; } } if (!$is_binary) { $result[] = "--- {$old_target}"; $result[] = "+++ {$cur_target}"; } $result[] = $change_body; } return implode("\n", $result)."\n"; } public function getChanges() { return $this->changes; } private function breakHunkIntoSmallHunks(ArcanistDiffHunk $hunk) { $context = 3; $results = array(); $lines = explode("\n", $hunk->getCorpus()); $n = count($lines); $old_offset = $hunk->getOldOffset(); $new_offset = $hunk->getNewOffset(); $ii = 0; $jj = 0; while ($ii < $n) { for ($jj = $ii; $jj < $n && $lines[$jj][0] == ' '; ++$jj) { // Skip lines until we find the first line with changes. } if ($jj >= $n) { break; } $hunk_start = max($jj - $context, 0); // NOTE: There are two tricky considerations here. // We can not generate a patch with overlapping hunks, or 'git apply' // rejects it after 1.7.3.4. // We can not generate a patch with too much trailing context, or // 'patch' rejects it. // So we need to ensure that we generate disjoint hunks, but don't // generate any hunks with too much context. $old_lines = 0; $new_lines = 0; $last_change = $jj; $break_here = null; for (; $jj < $n; ++$jj) { if ($lines[$jj][0] == ' ') { if ($jj - $last_change > $context) { if ($break_here === null) { // We haven't seen a change in $context lines, so this is a // potential place to break the hunk. However, we need to keep // looking in case there is another change fewer than $context // lines away, in which case we have to merge the hunks. $break_here = $jj; } } if ($jj - $last_change > (($context + 1) * 2)) { // We definitely aren't going to merge this with the next hunk, so // break out of the loop. We'll end the hunk at $break_here. break; } } else { $break_here = null; $last_change = $jj; if ($lines[$jj][0] == '-') { ++$old_lines; } else { ++$new_lines; } } } if ($break_here !== null) { $jj = $break_here; } $hunk_length = min($jj, $n) - $hunk_start; $hunk = new ArcanistDiffHunk(); $hunk->setOldOffset($old_offset + $hunk_start - $ii); $hunk->setNewOffset($new_offset + $hunk_start - $ii); $hunk->setOldLength($hunk_length - $new_lines); $hunk->setNewLength($hunk_length - $old_lines); $corpus = array_slice($lines, $hunk_start, $hunk_length); $corpus = implode("\n", $corpus); $hunk->setCorpus($corpus); $results[] = $hunk; $old_offset += ($jj - $ii) - $new_lines; $new_offset += ($jj - $ii) - $old_lines; $ii = $jj; } return $results; } private function getOldPath(ArcanistDiffChange $change) { $old_path = $change->getOldPath(); $type = $change->getType(); if (!strlen($old_path) || $type == ArcanistDiffChangeType::TYPE_ADD) { $old_path = null; } return $old_path; } private function getCurrentPath(ArcanistDiffChange $change) { $cur_path = $change->getCurrentPath(); $type = $change->getType(); if (!strlen($cur_path) || $type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $cur_path = null; } return $cur_path; } private function buildHunkChanges(array $hunks) { $result = array(); foreach ($hunks as $hunk) { $small_hunks = $this->breakHunkIntoSmallHunks($hunk); foreach ($small_hunks as $small_hunk) { $o_off = $small_hunk->getOldOffset(); $o_len = $small_hunk->getOldLength(); $n_off = $small_hunk->getNewOffset(); $n_len = $small_hunk->getNewLength(); $corpus = $small_hunk->getCorpus(); $result[] = "@@ -{$o_off},{$o_len} +{$n_off},{$n_len} @@"; $result[] = $corpus; } } return implode("\n", $result); } private function getBlob($phid) { if ($this->diskPath) { list($blob_data) = execx('tar xfO %s blobs/%s', $this->diskPath, $phid); return $blob_data; } if ($this->conduit) { echo "Downloading binary data...\n"; $data_base64 = $this->conduit->callMethodSynchronous( 'file.download', array( 'phid' => $phid, )); return base64_decode($data_base64); } throw new Exception("Nowhere to load blob '{$phid} from!"); } private function buildBinaryChange(ArcanistDiffChange $change) { $old_phid = $change->getMetadata('old:binary-phid', null); $new_phid = $change->getMetadata('new:binary-phid', null); $type = $change->getType(); if ($type == ArcanistDiffChangeType::TYPE_ADD) { $old_null = true; } else { $old_null = false; } if ($type == ArcanistDiffChangeType::TYPE_DELETE) { $new_null = true; } else { $new_null = false; } if ($old_null) { $old_data = ''; $old_length = 0; $old_sha1 = str_repeat('0', 40); } else { $old_data = $this->getBlob($old_phid); $old_length = strlen($old_data); $old_sha1 = sha1("blob {$old_length}\0{$old_data}"); } if ($new_null) { $new_data = ''; $new_length = 0; $new_sha1 = str_repeat('0', 40); } else { $new_data = $this->getBlob($new_phid); $new_length = strlen($new_data); $new_sha1 = sha1("blob {$new_length}\0{$new_data}"); } $content = array(); $content[] = "index {$old_sha1}..{$new_sha1}"; $content[] = "GIT binary patch"; $content[] = "literal {$new_length}"; $content[] = $this->emitBinaryDiffBody($new_data); $content[] = "literal {$old_length}"; $content[] = $this->emitBinaryDiffBody($old_data); return implode("\n", $content); } private function emitBinaryDiffBody($data) { // See emit_binary_diff_body() in diff.c for git's implementation. $buf = ''; $deflated = gzcompress($data); $lines = str_split($deflated, 52); foreach ($lines as $line) { $len = strlen($line); // The first character encodes the line length. if ($len <= 26) { $buf .= chr($len + ord('A') - 1); } else { $buf .= chr($len - 26 + ord('a') - 1); } $buf .= $this->encodeBase85($line); $buf .= "\n"; } $buf .= "\n"; return $buf; } private function encodeBase85($data) { // This is implemented awkwardly in order to closely mirror git's // implementation in base85.c static $map = array( '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '!', '#', '$', '%', '&', '(', ')', '*', '+', '-', ';', '<', '=', '>', '?', '@', '^', '_', '`', '{', '|', '}', '~', ); $buf = ''; $pos = 0; $bytes = strlen($data); while ($bytes) { $accum = '0'; for ($count = 24; $count >= 0; $count -= 8) { $val = ord($data[$pos++]); $val = bcmul($val, (string)(1 << $count)); $accum = bcadd($accum, $val); if (--$bytes == 0) { break; } } $slice = ''; for ($count = 4; $count >= 0; $count--) { $val = bcmod($accum, 85); $accum = bcdiv($accum, 85); $slice .= $map[$val]; } $buf .= strrev($slice); } return $buf; } } diff --git a/src/parser/diff/ArcanistDiffParser.php b/src/parser/diff/ArcanistDiffParser.php index 1a7d478d..c279e7c8 100644 --- a/src/parser/diff/ArcanistDiffParser.php +++ b/src/parser/diff/ArcanistDiffParser.php @@ -1,944 +1,944 @@ api = $api; return $this; } protected function getRepositoryAPI() { return $this->api; } public function setDetectBinaryFiles($detect) { $this->detectBinaryFiles = $detect; return $this; } public function setTryEncoding($encoding) { $this->tryEncoding = $encoding; } public function forcePath($path) { $this->forcePath = $path; return $this; } public function setChanges(array $changes) { $this->changes = mpull($changes, null, 'getCurrentPath'); return $this; } public function parseSubversionDiff(ArcanistSubversionAPI $api, $paths) { $this->setRepositoryAPI($api); $diffs = array(); foreach ($paths as $path => $status) { if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED || $status & ArcanistRepositoryAPI::FLAG_CONFLICT || $status & ArcanistRepositoryAPI::FLAG_MISSING) { unset($paths[$path]); } } $root = null; $from = array(); foreach ($paths as $path => $status) { $change = $this->buildChange($path); if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } else if ($status & ArcanistRepositoryAPI::FLAG_DELETED) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } else { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } $is_dir = is_dir($api->getPath($path)); if ($is_dir) { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); // We have to go hit the diff even for directories because they may // have property changes or moves, etc. } $is_link = is_link($api->getPath($path)); if ($is_link) { $change->setFileType(ArcanistDiffChangeType::FILE_SYMLINK); } $diff = $api->getRawDiffText($path); if ($diff) { $this->parseDiff($diff); } $info = $api->getSVNInfo($path); if (idx($info, 'Copied From URL')) { if (!$root) { $rinfo = $api->getSVNInfo('.'); $root = $rinfo['URL'].'/'; } $cpath = $info['Copied From URL']; $cpath = substr($cpath, strlen($root)); if ($info['Copied From Rev']) { // The user can "svn cp /path/to/file@12345 x", which pulls a file out // of version history at a specific revision. If we just use the path, // we'll collide with possible changes to that path in the working // copy below. In particular, "svn cp"-ing a path which no longer // exists somewhere in the working copy and then adding that path // gets us to the "origin change type" branches below with a // TYPE_ADD state on the path. To avoid this, append the origin // revision to the path so we'll necessarily generate a new change. // TODO: In theory, you could have an '@' in your path and this could // cause a collision, e.g. two files named 'f' and 'f@12345'. This is // at least somewhat the user's fault, though. if ($info['Copied From Rev'] != $info['Revision']) { $cpath .= '@'.$info['Copied From Rev']; } } $change->setOldPath($cpath); $from[$path] = $cpath; } } foreach ($paths as $path => $status) { $change = $this->buildChange($path); if (empty($from[$path])) { continue; } if (empty($this->changes[$from[$path]])) { if ($change->getType() == ArcanistDiffChangeType::TYPE_COPY_HERE) { // If the origin path wasn't changed (or isn't included in this diff) // and we only copied it, don't generate a changeset for it. This // keeps us out of trouble when we go to 'arc commit' and need to // figure out which files should be included in the commit list. continue; } } $origin = $this->buildChange($from[$path]); $origin->addAwayPath($change->getCurrentPath()); $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_COPY_AWAY: // "Add" is possible if you do some bizarre tricks with svn:ignore and // "svn copy"'ing URLs straight from the repository; you can end up with // a file that is a copy of itself. See T271. case ArcanistDiffChangeType::TYPE_ADD: break; case ArcanistDiffChangeType::TYPE_DELETE: $origin->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); break; case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $origin->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); break; case ArcanistDiffChangeType::TYPE_CHANGE: $origin->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); break; default: throw new Exception("Bad origin state {$type}."); } $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); break; case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_COPY_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); break; default: throw new Exception("Bad origin state {$type}."); } } return $this->changes; } public function parseDiff($diff) { $this->didStartParse($diff); if ($this->getLine() === null) { $this->didFailParse("Can't parse an empty diff!"); } do { $patterns = array( // This is a normal SVN text change, probably from "svn diff". '(?PIndex): (?P.+)', // This is an SVN property change, probably from "svn diff". '(?PProperty changes on): (?P.+)', // This is a git commit message, probably from "git show". '(?Pcommit) (?P[a-f0-9]+)', // This is a git diff, probably from "git show" or "git diff". '(?Pdiff --git) [abicwo12]/(?P.+) [abicwo12]/(?P.+)', // This is a unified diff, probably from "diff -u" or synthetic diffing. '(?P---) (?P.+)\s+\d{4}-\d{2}-\d{2}.*', '(?PBinary) files '. '(?P.+)\s+\d{4}-\d{2}-\d{2} and '. '(?P.+)\s+\d{4}-\d{2}-\d{2} differ.*', // This is a normal Mercurial text change, probably from "hg diff". '(?Pdiff -r) (?P[a-f0-9]+) (?P.+)', ); $ok = false; $line = $this->getLine(); $match = null; foreach ($patterns as $pattern) { $ok = preg_match('@^'.$pattern.'$@', $line, $match); if ($ok) { break; } } if (!$ok) { $this->didFailParse( "Expected a hunk header, like 'Index: /path/to/file.ext' (svn), ". "'Property changes on: /path/to/file.ext' (svn properties), ". "'commit 59bcc3ad6775562f845953cf01624225' (git show), ". "'diff --git' (git diff), or '--- filename' (unified diff)."); } $change = $this->buildChange(idx($match, 'cur')); if (isset($match['old'])) { $change->setOldPath($match['old']); } if (isset($match['hash'])) { $change->setCommitHash($match['hash']); } if (isset($match['binary'])) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); $line = $this->nextNonemptyLine(); continue; } $line = $this->nextLine(); switch ($match['type']) { case 'Index': $this->parseIndexHunk($change); break; case 'Property changes on': $this->parsePropertyHunk($change); break; case 'diff --git': $this->setIsGit(true); $this->parseIndexHunk($change); break; case 'commit': $this->setIsGit(true); $this->parseCommitMessage($change); break; case '---': $ok = preg_match( '@^(?:\+\+\+) (.*)\s+\d{4}-\d{2}-\d{2}.*$@', $line, $match); if (!$ok) { $this->didFailParse("Expected '+++ filename' in unified diff."); } $change->setCurrentPath($match[1]); $line = $this->nextLine(); $this->parseChangeset($change); break; case 'diff -r': $this->setIsMercurial(true); $this->parseIndexHunk($change); break; default: $this->didFailParse("Unknown diff type."); } } while ($this->getLine() !== null); $this->didFinishParse(); return $this->changes; } protected function parseCommitMessage(ArcanistDiffChange $change) { $change->setType(ArcanistDiffChangeType::TYPE_MESSAGE); $message = array(); $line = $this->getLine(); if (preg_match('/^Merge: /', $line)) { $this->nextLine(); } $line = $this->getLine(); if (!preg_match('/^Author: /', $line)) { $this->didFailParse("Expected 'Author:'."); } $line = $this->nextLine(); if (!preg_match('/^Date: /', $line)) { $this->didFailParse("Expected 'Date:'."); } while (($line = $this->nextLine()) !== null) { if (strlen($line) && $line[0] != ' ') { break; } // Strip leading spaces from Git commit messages. $message[] = substr($line, 4); } $message = rtrim(implode("\n", $message)); $change->setMetadata('message', $message); } /** * Parse an SVN property change hunk. These hunks are ambiguous so just sort * of try to get it mostly right. It's entirely possible to foil this parser * (or any other parser) with a carefully constructed property change. */ protected function parsePropertyHunk(ArcanistDiffChange $change) { $line = $this->getLine(); if (!preg_match('/^_+$/', $line)) { $this->didFailParse("Expected '______________________'."); } $line = $this->nextLine(); while ($line !== null) { $done = preg_match('/^(Index|Property changes on):/', $line); if ($done) { break; } $matches = null; $ok = preg_match('/^(Modified|Added|Deleted): (.*)$/', $line, $matches); if (!$ok) { $this->didFailParse("Expected 'Added', 'Deleted', or 'Modified'."); } $op = $matches[1]; $prop = $matches[2]; list($old, $new) = $this->parseSVNPropertyChange($op, $prop); if ($old !== null) { $change->setOldProperty($prop, $old); } if ($new !== null) { $change->setNewProperty($prop, $new); } $line = $this->getLine(); } } private function parseSVNPropertyChange($op, $prop) { $old = array(); $new = array(); $target = null; $line = $this->nextLine(); while ($line !== null) { $done = preg_match( '/^(Modified|Added|Deleted|Index|Property changes on):/', $line); if ($done) { break; } $trimline = ltrim($line); if ($trimline && $trimline[0] == '+') { if ($op == 'Deleted') { $this->didFailParse('Unexpected "+" section in property deletion.'); } $target = 'new'; $line = substr($trimline, 2); } else if ($trimline && $trimline[0] == '-') { if ($op == 'Added') { $this->didFailParse('Unexpected "-" section in property addition.'); } $target = 'old'; $line = substr($trimline, 2); } else if (!strncmp($trimline, 'Merged', 6)) { if ($op == 'Added') { $target = 'new'; } else { // These can appear on merges. No idea how to interpret this (unclear // what the old / new values are) and it's of dubious usefulness so // just throw it away until someone complains. $target = null; } $line = $trimline; } if ($target == 'new') { $new[] = $line; } else if ($target == 'old') { $old[] = $line; } $line = $this->nextLine(); } $old = rtrim(implode("\n", $old)); $new = rtrim(implode("\n", $new)); if (!strlen($old)) { $old = null; } if (!strlen($new)) { $new = null; } return array($old, $new); } protected function setIsGit($git) { if ($this->isGit !== null && $this->isGit != $git) { throw new Exception("Git status has changed!"); } $this->isGit = $git; return $this; } protected function getIsGit() { return $this->isGit; } public function setIsMercurial($is_mercurial) { $this->isMercurial = $is_mercurial; return $this; } public function getIsMercurial() { return $this->isMercurial; } protected function parseIndexHunk(ArcanistDiffChange $change) { $is_git = $this->getIsGit(); $is_mercurial = $this->getIsMercurial(); $is_svn = (!$is_git && !$is_mercurial); $line = $this->getLine(); if ($is_git) { do { $patterns = array( '(?Pnew) file mode (?P\d+)', '(?Pdeleted) file mode (?P\d+)', // These occur when someone uses `chmod` on a file. 'old mode (?P\d+)', 'new mode (?P\d+)', // These occur when you `mv` a file and git figures it out. 'similarity index ', 'rename from (?P.*)', '(?Prename) to (?P.*)', 'copy from (?P.*)', '(?Pcopy) to (?P.*)' ); $ok = false; $match = null; foreach ($patterns as $pattern) { $ok = preg_match('@^'.$pattern.'@', $line, $match); if ($ok) { break; } } if (!$ok) { if ($line === null || preg_match('/^(diff --git|commit) /', $line)) { // In this case, there are ONLY file mode changes, or this is a // pure move. return; } break; } if (!empty($match['oldmode'])) { $change->setOldProperty('unix:filemode', $match['oldmode']); } if (!empty($match['newmode'])) { $change->setNewProperty('unix:filemode', $match['newmode']); } if (!empty($match['deleted'])) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } if (!empty($match['new'])) { // If you replace a symlink with a normal file, git renders the change // as a "delete" of the symlink plus an "add" of the new file. We // prefer to represent this as a change. if ($change->getType() == ArcanistDiffChangeType::TYPE_DELETE) { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } else { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } } if (!empty($match['old'])) { $change->setOldPath($match['old']); } if (!empty($match['cur'])) { $change->setCurrentPath($match['cur']); } if (!empty($match['copy'])) { $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); } $old->addAwayPath($change->getCurrentPath()); } if (!empty($match['move'])) { $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MULTICOPY) { // Great, no change. } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); } $old->addAwayPath($change->getCurrentPath()); } $line = $this->nextNonemptyLine(); } while (true); } $line = $this->getLine(); if ($is_svn) { $ok = preg_match('/^=+$/', $line); if (!$ok) { $this->didFailParse("Expected '=======================' divider line."); } else { // Adding an empty file in SVN can produce an empty line here. $line = $this->nextNonemptyLine(); } } else if ($is_git) { $ok = preg_match('/^index .*$/', $line); if (!$ok) { // TODO: "hg diff -g" diffs ("mercurial git-style diffs") do not include // this line, so we can't parse them if we fail on it. Maybe introduce // a flag saying "parse this diff using relaxed git-style diff rules"? // $this->didFailParse("Expected 'index af23f...a98bc' header line."); } else { // NOTE: In the git case, where this patch is the last change in the // file, we may have a final terminal newline. Skip over it so that // we'll hit the '$line === null' block below. This is covered by the // 'git-empty-file.gitdiff' test case. $line = $this->nextNonemptyLine(); } } // If there are files with only whitespace changes and -b or -w are // supplied as command-line flags to `diff', svn and git both produce // changes without any body. if ($line === null || preg_match( '/^(Index:|Property changes on:|diff --git|commit) /', $line)) { return; } $is_binary_add = preg_match( '/^Cannot display: file marked as a binary type.$/', $line); if ($is_binary_add) { $this->nextLine(); // Cannot display: file marked as a binary type. $this->nextNonemptyLine(); // svn:mime-type = application/octet-stream $this->markBinary($change); return; } // We can get this in git, or in SVN when a file exists in the repository // WITHOUT a binary mime-type and is changed and given a binary mime-type. $is_binary_diff = preg_match( '/^Binary files .* and .* differ$/', $line); if ($is_binary_diff) { $this->nextNonemptyLine(); // Binary files x and y differ $this->markBinary($change); return; } // This occurs under "hg diff --git" when a binary file is removed. See // test case "hg-binary-delete.hgdiff". (I believe it never occurs under // git, which reports the "files X and /dev/null differ" string above. Git // can not apply these patches.) $is_hg_binary_delete = preg_match( '/^Binary file .* has changed$/', $line); if ($is_hg_binary_delete) { $this->nextNonemptyLine(); $this->markBinary($change); return; } // With "git diff --binary" (not a normal mode, but one users may explicitly // invoke and then, e.g., copy-paste into the web console) or "hg diff // --git" (normal under hg workflows), we may encounter a literal binary // patch. $is_git_binary_patch = preg_match( '/^GIT binary patch$/', $line); if ($is_git_binary_patch) { $this->nextLine(); $this->parseGitBinaryPatch(); $line = $this->getLine(); if (preg_match('/^literal/', $line)) { // We may have old/new binaries (change) or just a new binary (hg add). // If there are two blocks, parse both. $this->parseGitBinaryPatch(); } $this->markBinary($change); return; } if ($is_git) { // "git diff -b" ignores whitespace, but has an empty hunk target if (preg_match('@^diff --git a/.*$@', $line)) { $this->nextLine(); return null; } } $old_file = $this->parseHunkTarget(); $new_file = $this->parseHunkTarget(); $change->setOldPath($old_file); $this->parseChangeset($change); } private function parseGitBinaryPatch() { // TODO: We could decode the patches, but it's a giant mess so don't bother // for now. We'll pick up the data from the working copy in the common // case ("arc diff"). $line = $this->getLine(); if (!preg_match('/^literal /', $line)) { $this->didFailParse("Expected 'literal NNNN' to start git binary patch."); } do { $line = $this->nextLine(); if ($line === '' || $line === null) { // Some versions of Mercurial apparently omit the terminal newline, // although it's unclear if Git will ever do this. In either case, // rely on the base85 check for sanity. $this->nextNonemptyLine(); return; } else if (!preg_match('/^[a-zA-Z]/', $line)) { $this->didFailParse("Expected base85 line length character (a-zA-Z)."); } } while (true); } protected function parseHunkTarget() { $line = $this->getLine(); $matches = null; $remainder = '(?:\s*\(.*\))?'; if ($this->getIsMercurial()) { // Something like "Fri Aug 26 01:20:50 2005 -0700", don't bother trying // to parse it. $remainder = '\t.*'; } $ok = preg_match( '@^[-+]{3} (?:[ab]/)?(?P.*?)'.$remainder.'$@', $line, $matches); if (!$ok) { $this->didFailParse( "Expected hunk target '+++ path/to/file.ext (revision N)'."); } $this->nextLine(); return $matches['path']; } protected function markBinary(ArcanistDiffChange $change) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function parseChangeset(ArcanistDiffChange $change) { $all_changes = array(); do { $hunk = new ArcanistDiffHunk(); $line = $this->getLine(); $real = array(); // In the case where only one line is changed, the length is omitted. // The final group is for git, which appends a guess at the function // context to the diff. $matches = null; $ok = preg_match( '/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*?)?$/U', $line, $matches); if (!$ok) { $this->didFailParse("Expected hunk header '@@ -NN,NN +NN,NN @@'."); } $hunk->setOldOffset($matches[1]); $hunk->setNewOffset($matches[3]); // Cover for the cases where length wasn't present (implying one line). $old_len = idx($matches, 2); if (!strlen($old_len)) { $old_len = 1; } $new_len = idx($matches, 4); if (!strlen($new_len)) { $new_len = 1; } $hunk->setOldLength($old_len); $hunk->setNewLength($new_len); $add = 0; $del = 0; $advance = false; while ((($line = $this->nextLine()) !== null)) { if (strlen($line)) { $char = $line[0]; } else { $char = '~'; } switch ($char) { case '\\': if (!preg_match('@\\ No newline at end of file@', $line)) { $this->didFailParse( "Expected '\ No newline at end of file'."); } if ($new_len) { $hunk->setIsMissingOldNewline(true); } else { $hunk->setIsMissingNewNewline(true); } if (!$new_len) { $advance = true; break 2; } break; case '+': if (!$new_len) { break 2; } ++$add; --$new_len; $real[] = $line; break; case '-': if (!$old_len) { break 2; } ++$del; --$old_len; $real[] = $line; break; case ' ': if (!$old_len && !$new_len) { break 2; } --$old_len; --$new_len; $real[] = $line; break; case '~': $advance = true; break 2; default: break 2; } } if ($old_len != 0 || $new_len != 0) { $this->didFailParse("Found the wrong number of hunk lines."); } $corpus = implode("\n", $real); $is_binary = false; if ($this->detectBinaryFiles) { $is_binary = !phutil_is_utf8($corpus); if ($is_binary && $this->tryEncoding) { $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); if (!$is_binary) { // NOTE: This feature is HIGHLY EXPERIMENTAL and will cause a lot // of issues. Use it at your own risk. $corpus = mb_convert_encoding( $corpus, 'UTF-8', $this->tryEncoding); if (!phutil_is_utf8($corpus)) { throw new Exception( 'Failed converting hunk to '.$this->tryEncoding); } } } } if ($is_binary) { // SVN happily treats binary files which aren't marked with the right // mime type as text files. Detect that junk here and mark the file // binary. We'll catch stuff with unicode too, but that's verboten // anyway. If there are too many false positives with this we might // need to make it threshold-triggered instead of triggering on any // unprintable byte. $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); } else { $hunk->setCorpus($corpus); $hunk->setAddLines($add); $hunk->setDelLines($del); $change->addHunk($hunk); } if ($advance) { $line = $this->nextNonemptyLine(); } } while (preg_match('/^@@ /', $line)); } protected function buildChange($path = null) { $change = null; if ($path !== null) { if (!empty($this->changes[$path])) { return $this->changes[$path]; } } if ($this->forcePath) { return $this->changes[$this->forcePath]; } $change = new ArcanistDiffChange(); if ($path !== null) { $change->setCurrentPath($path); $this->changes[$path] = $change; } else { $this->changes[] = $change; } return $change; } protected function didStartParse($text) { // TODO: Removed an fb_utf8ize() call here. -epriestley // Eat leading whitespace. This may happen if the first change in the diff // is an SVN property change. $text = ltrim($text); $this->text = explode("\n", $text); $this->line = 0; } protected function getLine() { if ($this->text === null) { throw new Exception("Not parsing!"); } if (isset($this->text[$this->line])) { return $this->text[$this->line]; } return null; } protected function nextLine() { $this->line++; return $this->getLine(); } protected function nextNonemptyLine() { while (($line = $this->nextLine()) !== null) { if (strlen(trim($line)) !== 0) { break; } } return $this->getLine(); } protected function didFinishParse() { $this->text = null; } protected function didFailParse($message) { $min = max(0, $this->line - 3); $max = min($this->line + 3, count($this->text) - 1); $context = ''; for ($ii = $min; $ii <= $max; $ii++) { $context .= sprintf( "%8.8s %s\n", ($ii == $this->line) ? '>>> ' : '', $this->text[$ii]); } $message = "Parse Exception: {$message}\n\n{$context}\n"; throw new Exception($message); } } diff --git a/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php b/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php index 3bac9f98..5164db79 100644 --- a/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php +++ b/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php @@ -1,513 +1,513 @@ parseDiff($root.$file); } } private function parseDiff($diff_file) { $contents = Filesystem::readFile($diff_file); $file = basename($diff_file); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($contents); switch ($file) { case 'basic-missing-both-newlines-plus.udiff': case 'basic-missing-both-newlines.udiff': case 'basic-missing-new-newline-plus.udiff': case 'basic-missing-new-newline.udiff': case 'basic-missing-old-newline-plus.udiff': case 'basic-missing-old-newline.udiff': $expect_old = strpos($file, '-old-') || strpos($file, '-both-'); $expect_new = strpos($file, '-new-') || strpos($file, '-both-'); $expect_two = strpos($file, '-plus'); $this->assertEqual(count($changes), $expect_two ? 2 : 1); $change = reset($changes); $this->assertEqual(true, $change !== null); $hunks = $change->getHunks(); $this->assertEqual(1, count($hunks)); $hunk = reset($hunks); $this->assertEqual((bool)$expect_old, $hunk->getIsMissingOldNewline()); $this->assertEqual((bool)$expect_new, $hunk->getIsMissingNewNewline()); break; case 'basic-binary.udiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'basic-multi-hunk.udiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = $change->getHunks(); $this->assertEqual(4, count($hunks)); $this->assertEqual('right', $change->getCurrentPath()); $this->assertEqual('left', $change->getOldPath()); break; case 'basic-multi-hunk-content.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = $change->getHunks(); $this->assertEqual(2, count($hunks)); $there_is_a_literal_trailing_space_here = ' '; $corpus_0 = <<assertEqual( $corpus_0, $hunks[0]->getCorpus()); $this->assertEqual( $corpus_1, $hunks[1]->getCorpus()); break; case 'svn-ignore-whitespace-only.svndiff': $this->assertEqual(2, count($changes)); $hunks = reset($changes)->getHunks(); $this->assertEqual(0, count($hunks)); break; case 'svn-property-add.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = reset($changes)->getHunks(); $this->assertEqual(1, count($hunks)); $this->assertEqual( array( 'duck' => 'quack', ), $change->getNewProperties() ); break; case 'svn-property-modify.svndiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:ignore' => '*.phpz', ), $change->getOldProperties() ); $this->assertEqual( array( 'svn:ignore' => '*.php', ), $change->getNewProperties() ); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:special' => '*', ), $change->getOldProperties() ); $this->assertEqual( array( 'svn:special' => 'moo', ), $change->getNewProperties() ); break; case 'svn-property-delete.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( $change->getOldProperties(), array( 'svn:special' => '*', )); $this->assertEqual( array( ), $change->getNewProperties()); break; case 'svn-property-merged.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldProperties(), array()); $this->assertEqual( $change->getNewProperties(), array()); break; case 'svn-property-merge.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldProperties(), array( )); $this->assertEqual( $change->getNewProperties(), array( 'svn:mergeinfo' => <<assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:mime-type' => 'application/octet-stream', ), $change->getNewProperties() ); break; case 'svn-binary-diff.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(count($change->getHunks()), 0); break; case 'git-delete-file.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_DELETE, $change->getType()); $this->assertEqual( 'scripts/intern/test/testfile2', $change->getCurrentPath()); $this->assertEqual(1, count($change->getHunks())); break; case 'git-binary-change.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(0, count($change->getHunks())); break; case 'git-filemode-change.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(1, count($change->getHunks())); $this->assertEqual( array( 'unix:filemode' => '100644', ), $change->getOldProperties() ); $this->assertEqual( array( 'unix:filemode' => '100755', ), $change->getNewProperties() ); break; case 'git-filemode-change-only.gitdiff': $this->assertEqual(count($changes), 2); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( array( 'unix:filemode' => '100644', ), $change->getOldProperties() ); $this->assertEqual( array( 'unix:filemode' => '100755', ), $change->getNewProperties() ); break; case 'svn-empty-file.svndiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); break; case 'git-ignore-whitespace-only.gitdiff': $this->assertEqual(count($changes), 2); $change = array_shift($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldPath(), 'scripts/intern/test/testfile2'); $this->assertEqual( $change->getCurrentPath(), 'scripts/intern/test/testfile2'); $change = array_shift($changes); $this->assertEqual(count($change->getHunks()), 1); $this->assertEqual( $change->getOldPath(), 'scripts/intern/test/testfile3'); $this->assertEqual( $change->getCurrentPath(), 'scripts/intern/test/testfile3'); break; case 'git-move.gitdiff': case 'git-move-edit.gitdiff': case 'git-move-plus.gitdiff': $extra_changeset = (bool)strpos($file, '-plus'); $has_hunk = (bool)strpos($file, '-edit'); $this->assertEqual($extra_changeset ? 3 : 2, count($changes)); $change = array_shift($changes); $this->assertEqual($has_hunk ? 1 : 0, count($change->getHunks())); $this->assertEqual( $change->getType(), ArcanistDiffChangeType::TYPE_MOVE_HERE); $target = $change; $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_MOVE_AWAY, $change->getType() ); $this->assertEqual( $change->getCurrentPath(), $target->getOldPath()); $this->assertEqual( true, in_array($target->getCurrentPath(), $change->getAwayPaths())); break; case 'git-merge-header.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_MESSAGE, $change->getType()); $this->assertEqual( '501f6d519703458471dbea6284ec5f49d1408598', $change->getCommitHash()); break; case 'git-new-file.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_ADD, $change->getType()); break; case 'git-copy.gitdiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_HERE, $change->getType()); $this->assertEqual( 'flib/intern/widgets/ui/UIWidgetRSSBox.php', $change->getCurrentPath()); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_AWAY, $change->getType()); $this->assertEqual( 'lib/display/intern/ui/widget/UIWidgetRSSBox.php', $change->getCurrentPath()); break; case 'git-copy-plus.gitdiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(3, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_HERE, $change->getType()); $this->assertEqual( 'flib/intern/widgets/ui/UIWidgetGraphConnect.php', $change->getCurrentPath()); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_AWAY, $change->getType()); $this->assertEqual( 'lib/display/intern/ui/widget/UIWidgetLunchtime.php', $change->getCurrentPath()); break; case 'svn-property-multiline.svndiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:ignore' => 'tags', ), $change->getOldProperties() ); $this->assertEqual( array( 'svn:ignore' => "tags\nasdf\nlol\nwhat", ), $change->getNewProperties() ); break; case 'git-empty-files.gitdiff': $this->assertEqual(2, count($changes)); while ($change = array_shift($changes)) { $this->assertEqual(0, count($change->getHunks())); } break; case 'git-mnemonicprefix.gitdiff': // Check parsing of diffs created with `diff.mnemonicprefix` // configuration option set to `true`. $this->assertEqual(1, count($changes)); $this->assertEqual(1, count(reset($changes)->getHunks())); break; case 'git-commit.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_MESSAGE, $change->getType()); $this->assertEqual( '76e2f1339c298c748aa0b52030799ed202a6537b', $change->getCommitHash()); $this->assertEqual( <<. I tested most of these calls, but there were some that I didn't know how to reach, so if you are one of the owners of this code, please test your feature in my sandbox: www.ngao.devrs013.facebook.com @brosenthal, I removed some logic that was setting a disabled state on a UIActionButton, which is actually a no-op. Reviewed By: brosenthal Other Commenters: sparker, egiovanola Test Plan: www.ngao.devrs013.facebook.com Explicitly tested: * ads creation flow (add keyword) * ads manager (conversion tracking) * help center (create a discussion) * new user wizard (next step button) Revert: OK DiffCamp Revision: 94064 git-svn-id: svn+ssh://tubbs/svnroot/tfb/trunk/www@223593 2c7ba8d8 EOTEXT , $change->getMetadata('message') ); break; case 'git-binary.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'hg-binary-change.hgdiff': case 'hg-solo-binary-change.hgdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_ADD, $change->getType()); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'hg-binary-delete.hgdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_DELETE, $change->getType()); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'git-replace-symlink.gitdiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); break; default: throw new Exception("No test block for diff file {$diff_file}."); break; } } } diff --git a/src/parser/diff/change/ArcanistDiffChange.php b/src/parser/diff/change/ArcanistDiffChange.php index 3b093501..16ab7c3d 100644 --- a/src/parser/diff/change/ArcanistDiffChange.php +++ b/src/parser/diff/change/ArcanistDiffChange.php @@ -1,242 +1,242 @@ hunks as $hunk) { $hunks[] = $hunk->toDictionary(); } return array( 'metadata' => $this->metadata, 'oldPath' => $this->oldPath, 'currentPath' => $this->currentPath, 'awayPaths' => $this->awayPaths, 'oldProperties' => $this->oldProperties, 'newProperties' => $this->newProperties, 'type' => $this->type, 'fileType' => $this->fileType, 'commitHash' => $this->commitHash, 'hunks' => $hunks, ); } public static function newFromDictionary(array $dict) { $hunks = array(); foreach ($dict['hunks'] as $hunk) { $hunks[] = ArcanistDiffHunk::newFromDictionary($hunk); } $obj = new ArcanistDiffChange(); $obj->metadata = $dict['metadata']; $obj->oldPath = $dict['oldPath']; $obj->currentPath = $dict['currentPath']; // TODO: The backend is shipping down some bogus data, e.g. diff 199453. // Should probably clean this up. $obj->awayPaths = nonempty($dict['awayPaths'], array()); $obj->oldProperties = nonempty($dict['oldProperties'], array()); $obj->newProperties = nonempty($dict['newProperties'], array()); $obj->type = $dict['type']; $obj->fileType = $dict['fileType']; $obj->commitHash = $dict['commitHash']; $obj->hunks = $hunks; return $obj; } public function getChangedLines($type) { $lines = array(); foreach ($this->hunks as $hunk) { $lines += $hunk->getChangedLines($type); } return $lines; } public function getAllMetadata() { return $this->metadata; } public function setMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function getMetadata($key) { return idx($this->metadata, $key); } public function setCommitHash($hash) { $this->commitHash = $hash; return $this; } public function getCommitHash() { return $this->commitHash; } public function addAwayPath($path) { $this->awayPaths[] = $path; return $this; } public function getAwayPaths() { return $this->awayPaths; } public function setFileType($type) { $this->fileType = $type; return $this; } public function getFileType() { return $this->fileType; } public function setType($type) { $this->type = $type; return $this; } public function getType() { return $this->type; } public function setOldProperty($key, $value) { $this->oldProperties[$key] = $value; return $this; } public function setNewProperty($key, $value) { $this->newProperties[$key] = $value; return $this; } public function getOldProperties() { return $this->oldProperties; } public function getNewProperties() { return $this->newProperties; } public function setCurrentPath($path) { $this->currentPath = $this->filterPath($path); return $this; } public function getCurrentPath() { return $this->currentPath; } public function setOldPath($path) { $this->oldPath = $this->filterPath($path); return $this; } public function getOldPath() { return $this->oldPath; } public function addHunk(ArcanistDiffHunk $hunk) { $this->hunks[] = $hunk; return $this; } public function getHunks() { return $this->hunks; } public function convertToBinaryChange() { $this->hunks = array(); $this->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function filterPath($path) { if ($path == '/dev/null') { return null; } return $path; } public function renderTextSummary() { $type = $this->getType(); $file = $this->getFileType(); $char = ArcanistDiffChangeType::getSummaryCharacterForChangeType($type); $attr = ArcanistDiffChangeType::getShortNameForFileType($file); if ($attr) { $attr = '('.$attr.')'; } $summary = array(); $summary[] = sprintf( "%s %5.5s %s", $char, $attr, $this->getCurrentPath()); if (ArcanistDiffChangeType::isOldLocationChangeType($type)) { foreach ($this->getAwayPaths() as $path) { $summary[] = ' to: '.$path; } } if (ArcanistDiffChangeType::isNewLocationChangeType($type)) { $summary[] = ' from: '.$this->getOldPath(); } return implode("\n", $summary); } public function getSymlinkTarget() { if ($this->getFileType() != ArcanistDiffChangeType::FILE_SYMLINK) { throw new Exception("Not a symlink!"); } $hunks = $this->getHunks(); $hunk = reset($hunks); $corpus = $hunk->getCorpus(); $match = null; if (!preg_match('/^\+(?:link )?(.*)$/m', $corpus, $match)) { throw new Exception("Failed to extract link target!"); } return trim($match[1]); } } diff --git a/src/parser/diff/changetype/ArcanistDiffChangeType.php b/src/parser/diff/changetype/ArcanistDiffChangeType.php index be68a5b6..fb3ea464 100644 --- a/src/parser/diff/changetype/ArcanistDiffChangeType.php +++ b/src/parser/diff/changetype/ArcanistDiffChangeType.php @@ -1,129 +1,129 @@ 'A', self::TYPE_CHANGE => 'M', self::TYPE_DELETE => 'D', self::TYPE_MOVE_AWAY => 'V', self::TYPE_COPY_AWAY => 'P', self::TYPE_MOVE_HERE => 'V', self::TYPE_COPY_HERE => 'P', self::TYPE_MULTICOPY => 'P', self::TYPE_MESSAGE => 'Q', self::TYPE_CHILD => '@', ); return idx($types, coalesce($type, '?'), '~'); } public static function getShortNameForFileType($type) { static $names = array( self::FILE_TEXT => null, self::FILE_DIRECTORY => 'dir', self::FILE_IMAGE => 'img', self::FILE_BINARY => 'bin', self::FILE_SYMLINK => 'sym', ); return idx($names, coalesce($type, '?'), '???'); } public static function isOldLocationChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_MOVE_AWAY => true, ArcanistDiffChangeType::TYPE_COPY_AWAY => true, ArcanistDiffChangeType::TYPE_MULTICOPY => true, ); return isset($types[$type]); } public static function isNewLocationChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_MOVE_HERE => true, ArcanistDiffChangeType::TYPE_COPY_HERE => true, ); return isset($types[$type]); } public static function isDeleteChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_DELETE => true, ArcanistDiffChangeType::TYPE_MOVE_AWAY => true, ArcanistDiffChangeType::TYPE_MULTICOPY => true, ); return isset($types[$type]); } public static function isCreateChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_ADD => true, ArcanistDiffChangeType::TYPE_COPY_HERE => true, ArcanistDiffChangeType::TYPE_MOVE_HERE => true, ); return isset($types[$type]); } public static function isModifyChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_CHANGE => true, ); return isset($types[$type]); } public static function getFullNameForChangeType($type) { static $types = array( self::TYPE_ADD => 'Added', self::TYPE_CHANGE => 'Modified', self::TYPE_DELETE => 'Deleted', self::TYPE_MOVE_AWAY => 'Moved Away', self::TYPE_COPY_AWAY => 'Copied Away', self::TYPE_MOVE_HERE => 'Moved Here', self::TYPE_COPY_HERE => 'Copied Here', self::TYPE_MULTICOPY => 'Deleted After Multiple Copy', self::TYPE_MESSAGE => 'Commit Message', self::TYPE_CHILD => 'Contents Modified', ); return idx($types, coalesce($type, '?'), 'Unknown'); } } diff --git a/src/parser/diff/hunk/ArcanistDiffHunk.php b/src/parser/diff/hunk/ArcanistDiffHunk.php index e634f621..150815de 100644 --- a/src/parser/diff/hunk/ArcanistDiffHunk.php +++ b/src/parser/diff/hunk/ArcanistDiffHunk.php @@ -1,189 +1,189 @@ $this->oldOffset, 'newOffset' => $this->newOffset, 'oldLength' => $this->oldLength, 'newLength' => $this->newLength, 'addLines' => $this->addLines, 'delLines' => $this->delLines, 'isMissingOldNewline' => $this->isMissingOldNewline, 'isMissingNewNewline' => $this->isMissingNewNewline, 'corpus' => (string)$this->corpus, ); } public static function newFromDictionary(array $dict) { $obj = new ArcanistDiffHunk(); $obj->oldOffset = $dict['oldOffset']; $obj->newOffset = $dict['newOffset']; $obj->oldLength = $dict['oldLength']; $obj->newLength = $dict['newLength']; $obj->addLines = $dict['addLines']; $obj->delLines = $dict['delLines']; $obj->isMissingOldNewline = $dict['isMissingOldNewline']; $obj->isMissingNewNewline = $dict['isMissingNewNewline']; $obj->corpus = $dict['corpus']; return $obj; } public function getChangedLines($type) { $old_map = array(); $new_map = array(); $cover_map = array(); $oline = $this->getOldOffset(); $nline = $this->getNewOffset(); foreach (explode("\n", $this->getCorpus()) as $line) { $char = strlen($line) ? $line[0] : '~'; switch ($char) { case '-': $old_map[$oline] = true; $cover_map[$oline] = true; ++$oline; break; case '+': $new_map[$nline] = true; if ($oline > 1) { $cover_map[$oline - 1] = true; } $cover_map[$oline] = true; ++$nline; break; default: ++$oline; ++$nline; break; } } switch ($type) { case 'new': return $new_map; case 'old': return $old_map; case 'cover': return $cover_map; default: throw new Exception("Unknown line change type '{$type}'."); } } public function setOldOffset($old_offset) { $this->oldOffset = $old_offset; return $this; } public function getOldOffset() { return $this->oldOffset; } public function setNewOffset($new_offset) { $this->newOffset = $new_offset; return $this; } public function getNewOffset() { return $this->newOffset; } public function setOldLength($old_length) { $this->oldLength = $old_length; return $this; } public function getOldLength() { return $this->oldLength; } public function setNewLength($new_length) { $this->newLength = $new_length; return $this; } public function getNewLength() { return $this->newLength; } public function setAddLines($add_lines) { $this->addLines = $add_lines; return $this; } public function getAddLines() { return $this->addLines; } public function setDelLines($del_lines) { $this->delLines = $del_lines; return $this; } public function getDelLines() { return $this->delLines; } public function setCorpus($corpus) { $this->corpus = $corpus; return $this; } public function getCorpus() { return $this->corpus; } public function setIsMissingOldNewline($missing) { $this->isMissingOldNewline = (bool)$missing; return $this; } public function getIsMissingOldNewline() { return $this->isMissingOldNewline; } public function setIsMissingNewNewline($missing) { $this->isMissingNewNewline = (bool)$missing; return $this; } public function getIsMissingNewNewline() { return $this->isMissingNewNewline; } } diff --git a/src/parser/phutilmodule/PhutilModuleRequirements.php b/src/parser/phutilmodule/PhutilModuleRequirements.php index 894932b6..4b50e4b9 100644 --- a/src/parser/phutilmodule/PhutilModuleRequirements.php +++ b/src/parser/phutilmodule/PhutilModuleRequirements.php @@ -1,177 +1,177 @@ array(), 'interface' => array(), 'function' => array(), ); protected $requires = array( 'class' => array(), 'interface' => array(), 'function' => array(), 'source' => array(), 'module' => array(), ); protected $declares = array( 'class' => array(), 'interface' => array(), 'function' => array(), 'source' => array(), ); protected $chain = array( ); protected $currentFile; protected $messages = array( ); public function setCurrentFile($current_file) { $this->currentFile = $current_file; return $this; } protected function getCurrentFile() { return $this->currentFile; } protected function getWhere(XHPASTNode $where) { return $this->getCurrentFile().':'.$where->getOffset(); } public function addClassDeclaration(XHPASTNode $where, $name) { return $this->addDeclaration('class', $where, $name); } public function addFunctionDeclaration(XHPASTNode $where, $name) { return $this->addDeclaration('function', $where, $name); } public function addInterfaceDeclaration(XHPASTNode $where, $name) { return $this->addDeclaration('interface', $where, $name); } public function addSourceDeclaration($name) { $this->declares['source'][$name] = true; return $this; } protected function addDeclaration($type, XHPASTNode $where, $name) { $this->declares[$type][$name] = $this->getWhere($where); return $this; } protected function addDependency($type, XHPASTNode $where, $name) { if (isset($this->builtins[$type][$name])) { return $this; } if (empty($this->requires[$type][$name])) { $this->requires[$type][$name] = array(); } $this->requires[$type][$name][] = $this->getWhere($where); return $this; } public function addClassDependency($child, XHPASTNode $where, $name) { if ($child !== null) { if (empty($this->builtins['class'][$name])) { $this->chain['class'][$child] = $name; } } return $this->addDependency('class', $where, $name); } public function addFunctionDependency(XHPASTNode $where, $name) { return $this->addDependency('function', $where, $name); } public function addInterfaceDependency($child, XHPASTNode $where, $name) { if ($child !== null) { if (empty($this->builtins['interface'][$name])) { $this->chain['interface'][$child][] = $name; } } return $this->addDependency('interface', $where, $name); } public function addSourceDependency(XHPASTNode $where, $name) { return $this->addDependency('source', $where, $name); } public function addModuleDependency(XHPASTNode $where, $name) { return $this->addDependency('module', $where, $name); } public function addBuiltins(array $builtins) { foreach ($builtins as $type => $symbol_set) { $this->builtins[$type] += $symbol_set; } return $this; } public function addRawLint($code, $message) { $this->messages[] = array( null, null, $code, $message); return $this; } public function addLint(XHPASTNode $where, $text, $code, $message) { $this->messages[] = array( $this->getWhere($where), $text, $code, $message); return $this; } public function toDictionary() { // Remove all dependencies on things which we declare since they're never // useful and guaranteed to be satisfied. foreach ($this->declares as $type => $things) { if ($type == 'source') { // Source is treated specially since we only reconcile it locally. continue; } foreach ($things as $name => $where) { unset($this->requires[$type][$name]); } } return array( 'declares' => $this->declares, 'requires' => $this->requires, 'chain' => $this->chain, 'messages' => $this->messages, ); } } diff --git a/src/repository/api/git/ArcanistGitAPI.php b/src/repository/api/git/ArcanistGitAPI.php index c25f3d66..890f17ab 100644 --- a/src/repository/api/git/ArcanistGitAPI.php +++ b/src/repository/api/git/ArcanistGitAPI.php @@ -1,680 +1,680 @@ repositoryHasNoCommits; } public function setRelativeCommit($relative_commit) { $this->relativeCommit = $relative_commit; return $this; } public function getLocalCommitInformation() { if ($this->repositoryHasNoCommits) { // Zero commits. throw new Exception( "You can't get local commit information for a repository with no ". "commits."); } else if ($this->relativeCommit == self::GIT_MAGIC_ROOT_COMMIT) { // One commit. $against = 'HEAD'; } else { // 2..N commits. $against = $this->getRelativeCommit().'..HEAD'; } list($info) = execx( '(cd %s && git log %s --format=%s --)', $this->getPath(), $against, '%H%x00%T%x00%P%x00%at%x00%an%x00%s'); $commits = array(); $info = trim($info); $info = explode("\n", $info); foreach ($info as $line) { list($commit, $tree, $parents, $time, $author, $title) = explode("\0", $line, 6); $commits[] = array( 'commit' => $commit, 'tree' => $tree, 'parents' => array_filter(explode(' ', $parents)), 'time' => $time, 'author' => $author, 'summary' => $title, ); } return $commits; } public function getRelativeCommit() { if ($this->relativeCommit === null) { list($err) = exec_manual( '(cd %s; git rev-parse --verify HEAD^)', $this->getPath()); if ($err) { list($err) = exec_manual( '(cd %s; git rev-parse --verify HEAD)', $this->getPath()); if ($err) { $this->repositoryHasNoCommits = true; } $this->relativeCommit = self::GIT_MAGIC_ROOT_COMMIT; } else { $this->relativeCommit = 'HEAD^'; } } return $this->relativeCommit; } private function getDiffFullOptions() { $options = array( self::getDiffBaseOptions(), '-M', '-C', '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } private function getDiffBaseOptions() { $options = array( // Disable external diff drivers, like graphical differs, since Arcanist // needs to capture the diff text. '--no-ext-diff', // Disable textconv so we treat binary files as binary, even if they have // an alternative textual representation. TODO: Ideally, Differential // would ship up the binaries for 'arc patch' but display the textconv // output in the visual diff. '--no-textconv', ); return implode(' ', $options); } public function getFullGitDiff() { $options = $this->getDiffFullOptions(); list($stdout) = execx( "(cd %s; git diff {$options} %s --)", $this->getPath(), $this->getRelativeCommit()); return $stdout; } public function getRawDiffText($path) { $options = $this->getDiffFullOptions(); list($stdout) = execx( "(cd %s; git diff {$options} %s -- %s)", $this->getPath(), $this->getRelativeCommit(), $path); return $stdout; } public function getBranchName() { // TODO: consider: // // $ git rev-parse --abbrev-ref `git symbolic-ref HEAD` // // But that may fail if you're not on a branch. list($stdout) = execx( '(cd %s; git branch)', $this->getPath()); $matches = null; if (preg_match('/^\* (.+)$/m', $stdout, $matches)) { return $matches[1]; } return null; } public function getSourceControlPath() { // TODO: Try to get something useful here. return null; } public function getGitCommitLog() { $relative = $this->getRelativeCommit(); if ($this->repositoryHasNoCommits) { // No commits yet. return ''; } else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) { // First commit. list($stdout) = execx( '(cd %s; git log --format=medium HEAD)', $this->getPath()); } else { // 2..N commits. list($stdout) = execx( '(cd %s; git log --first-parent --format=medium %s..HEAD)', $this->getPath(), $this->getRelativeCommit()); } return $stdout; } public function getGitHistoryLog() { list($stdout) = execx( '(cd %s; git log --format=medium -n%d %s)', $this->getPath(), self::SEARCH_LENGTH_FOR_PARENT_REVISIONS, $this->getRelativeCommit()); return $stdout; } public function getSourceControlBaseRevision() { list($stdout) = execx( '(cd %s; git rev-parse %s)', $this->getPath(), $this->getRelativeCommit()); return rtrim($stdout, "\n"); } /** * Returns the sha1 of the HEAD revision * @param boolean $short whether return the abbreviated or full hash. */ public function getGitHeadRevision($short=false) { if ($short) { $flags = '--short'; } else { $flags = ''; } list($stdout) = execx( '(cd %s; git rev-parse %s HEAD)', $this->getPath(), $flags); return rtrim($stdout, "\n"); } public function getWorkingCopyStatus() { if (!isset($this->status)) { $options = $this->getDiffBaseOptions(); // -- parallelize these slow cpu bound git calls. // Find committed changes. $committed_future = new ExecFuture( "(cd %s; git diff {$options} --raw %s --)", $this->getPath(), $this->getRelativeCommit()); // Find uncommitted changes. $uncommitted_future = new ExecFuture( "(cd %s; git diff {$options} --raw %s --)", $this->getPath(), $this->repositoryHasNoCommits ? self::GIT_MAGIC_ROOT_COMMIT : 'HEAD'); // Untracked files $untracked_future = new ExecFuture( '(cd %s; git ls-files --others --exclude-standard)', $this->getPath()); // TODO: This doesn't list unstaged adds. It's not clear how to get that // list other than "git status --porcelain" and then parsing it. :/ // Unstaged changes $unstaged_future = new ExecFuture( '(cd %s; git ls-files -m)', $this->getPath()); $futures = array( $committed_future, $uncommitted_future, $untracked_future, $unstaged_future ); Futures($futures)->resolveAll(); // -- read back and process the results list($stdout, $stderr) = $committed_future->resolvex(); $files = $this->parseGitStatus($stdout); list($stdout, $stderr) = $uncommitted_future->resolvex(); $uncommitted_files = $this->parseGitStatus($stdout); foreach ($uncommitted_files as $path => $mask) { $mask |= self::FLAG_UNCOMMITTED; if (!isset($files[$path])) { $files[$path] = 0; } $files[$path] |= $mask; } list($stdout, $stderr) = $untracked_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = self::FLAG_UNTRACKED; } } list($stdout, $stderr) = $unstaged_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = isset($files[$file]) ? ($files[$file] | self::FLAG_UNSTAGED) : self::FLAG_UNSTAGED; } } $this->status = $files; } return $this->status; } public function amendGitHeadCommit($message) { execx( '(cd %s; git commit --amend --allow-empty --message %s)', $this->getPath(), $message); } public function getPreReceiveHookStatus($old_ref, $new_ref) { $options = $this->getDiffBaseOptions(); list($stdout) = execx( "(cd %s && git diff {$options} --raw %s %s --)", $this->getPath(), $old_ref, $new_ref); return $this->parseGitStatus($stdout, $full = true); } private function parseGitStatus($status, $full = false) { static $flags = array( 'A' => self::FLAG_ADDED, 'M' => self::FLAG_MODIFIED, 'D' => self::FLAG_DELETED, ); $status = trim($status); $lines = array(); foreach (explode("\n", $status) as $line) { if ($line) { $lines[] = preg_split("/[ \t]/", $line); } } $files = array(); foreach ($lines as $line) { $mask = 0; $flag = $line[4]; $file = $line[5]; foreach ($flags as $key => $bits) { if ($flag == $key) { $mask |= $bits; } } if ($full) { $files[$file] = array( 'mask' => $mask, 'ref' => rtrim($line[3], '.'), ); } else { $files[$file] = $mask; } } return $files; } public function getBlame($path) { // TODO: 'git blame' supports --porcelain and we should probably use it. list($stdout) = execx( '(cd %s; git blame --date=iso -w -M %s -- %s)', $this->getPath(), $this->getRelativeCommit(), $path); $blame = array(); foreach (explode("\n", trim($stdout)) as $line) { if (!strlen($line)) { continue; } // lines predating a git repo's history are blamed to the oldest revision, // with the commit hash prepended by a ^. we shouldn't count these lines // as blaming to the oldest diff's unfortunate author if ($line[0] == '^') { continue; } $matches = null; $ok = preg_match( '/^([0-9a-f]+)[^(]+?[(](.*?) +\d\d\d\d-\d\d-\d\d/', $line, $matches); if (!$ok) { throw new Exception("Bad blame? `{$line}'"); } $revision = $matches[1]; $author = $matches[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getRelativeCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision($path, 'HEAD'); } private function parseGitTree($stdout) { $result = array(); $stdout = trim($stdout); if (!strlen($stdout)) { return $result; } $lines = explode("\n", $stdout); foreach ($lines as $line) { $matches = array(); $ok = preg_match( '/^(\d{6}) (blob|tree) ([a-z0-9]{40})[\t](.*)$/', $line, $matches); if (!$ok) { throw new Exception("Failed to parse git ls-tree output!"); } $result[$matches[4]] = array( 'mode' => $matches[1], 'type' => $matches[2], 'ref' => $matches[3], ); } return $result; } private function getFileDataAtRevision($path, $revision) { // NOTE: We don't want to just "git show {$revision}:{$path}" since if the // path was a directory at the given revision we'll get a list of its files // and treat it as though it as a file containing a list of other files, // which is silly. list($stdout) = execx( '(cd %s && git ls-tree %s -- %s)', $this->getPath(), $revision, $path); $info = $this->parseGitTree($stdout); if (empty($info[$path])) { // No such path, or the path is a directory and we executed 'ls-tree dir/' // and got a list of its contents back. return null; } if ($info[$path]['type'] != 'blob') { // Path is or was a directory, not a file. return null; } list($stdout) = execx( '(cd %s && git cat-file blob %s)', $this->getPath(), $info[$path]['ref']); return $stdout; } /** * Returns names of all the branches in the current repository. * * @return array where each element is a triple ('name', 'sha1', 'current') */ public function getAllBranches() { list($branch_info) = execx( 'cd %s && git branch --no-color', $this->getPath()); $lines = explode("\n", trim($branch_info)); $result = array(); foreach ($lines as $line) { $match = array(); preg_match('/^(\*?)\s*(.*)$/', $line, $match); $name = $match[2]; if ($name == '(no branch)') { // Just ignore this, we could theoretically try to figure out the ref // and treat it like a real branch but that's sort of ridiculous. continue; } $result[] = array( 'current' => !empty($match[1]), 'name' => $name, ); } $all_names = ipull($result, 'name'); // Calling 'git branch' first and then 'git rev-parse' is way faster than // 'git branch -v' for some reason. list($sha1s_string) = execx( "cd %s && git rev-parse %Ls", $this->path, $all_names); $sha1_map = array_combine($all_names, explode("\n", trim($sha1s_string))); foreach ($result as &$branch) { $branch['sha1'] = $sha1_map[$branch['name']]; } return $result; } /** * Returns git commit messages for the given revisions, * in the specified format (see git show --help for options). * * @param array $revs a list of commit hashes * @param string $format the format to show messages in */ public function multigetCommitMessages($revs, $format) { $delimiter = "%%x00"; $revs_list = implode(' ', $revs); $show_command = "git show -s --pretty=\"format:$format$delimiter\" $revs_list"; list($commits_string) = execx( "cd %s && $show_command", $this->getPath()); $commits_list = array_slice(explode("\0", $commits_string), 0, -1); $commits_list = array_combine($revs, $commits_list); return $commits_list; } public function getRepositoryOwner() { list($owner) = execx( 'cd %s && git config --get user.name', $this->getPath()); return trim($owner); } public function getWorkingCopyRevision() { list($stdout) = execx( '(cd %s; git rev-parse %s)', $this->getPath(), 'HEAD'); return rtrim($stdout, "\n"); } public function supportsRelativeLocalCommits() { return true; } public function parseRelativeLocalCommit(array $argv) { if (count($argv) == 0) { return; } if (count($argv) != 1) { throw new ArcanistUsageException("Specify only one commit."); } $base = reset($argv); if ($base == ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) { $merge_base = $base; } else { list($err, $merge_base) = exec_manual( '(cd %s; git merge-base %s HEAD)', $this->getPath(), $base); if ($err) { throw new ArcanistUsageException( "Unable to find any git commit named '{$base}' in this repository."); } } $this->setRelativeCommit(trim($merge_base)); } public function getAllLocalChanges() { $diff = $this->getFullGitDiff(); $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function supportsLocalBranchMerge() { return true; } public function performLocalBranchMerge($branch, $message) { if (!$branch) { throw new ArcanistUsageException( "Under git, you must specify the branch you want to merge."); } $err = phutil_passthru( '(cd %s && git merge --no-ff -m %s %s)', $this->getPath(), $message, $branch); if ($err) { throw new ArcanistUsageException("Merge failed!"); } } public function getFinalizedRevisionMessage() { return "You may now push this commit upstream, as appropriate (e.g. with ". "'git push', or 'git svn dcommit', or by printing and faxing it)."; } public function getCommitMessageForRevision($rev) { list($message) = execx( '(cd %s && git log -n1 %s)', $this->getPath(), $rev); $parser = new ArcanistDiffParser(); return head($parser->parseDiff($message)); } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getGitCommitLog(); if (!strlen($messages)) { return array(); } $parser = new ArcanistDiffParser(); $messages = $parser->parseDiff($messages); // First, try to find revisions by explicit revision IDs in commit messages. $revision_ids = array(); foreach ($messages as $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus( $message->getMetadata('message')); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); return $results; } // If we didn't succeed, try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('gtcm', $commit['commit']); $hashes[] = array('gttr', $commit['tree']); } $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); if ($results) { return $results; } // If we still didn't succeed, try to find revisions by branch name. $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'branches' => array($this->getBranchName()), )); return $results; } } diff --git a/src/repository/api/mercurial/ArcanistMercurialAPI.php b/src/repository/api/mercurial/ArcanistMercurialAPI.php index 9aa736b9..f7c54ffb 100644 --- a/src/repository/api/mercurial/ArcanistMercurialAPI.php +++ b/src/repository/api/mercurial/ArcanistMercurialAPI.php @@ -1,407 +1,407 @@ getPath(), '{node}\\n', $this->getRelativeCommit()); return rtrim($stdout, "\n"); } public function getSourceControlPath() { return '/'; } public function getBranchName() { // TODO: I have nearly no idea how hg branches work. list($stdout) = execx( '(cd %s && hg branch)', $this->getPath()); return trim($stdout); } public function setRelativeCommit($commit) { list($err) = exec_manual( '(cd %s && hg id -ir %s)', $this->getPath(), $commit); if ($err) { throw new ArcanistUsageException( "Commit '{$commit}' is not a valid Mercurial commit identifier."); } $this->relativeCommit = $commit; return $this; } public function getRelativeCommit() { if (empty($this->relativeCommit)) { list($stdout) = execx( '(cd %s && hg outgoing --branch `hg branch` --style default)', $this->getPath()); $logs = ArcanistMercurialParser::parseMercurialLog($stdout); if (!count($logs)) { throw new ArcanistUsageException("You have no outgoing changes!"); } $outgoing_revs = ipull($logs, 'rev'); // This is essentially an implementation of a theoretical `hg merge-base` // command. $against = 'tip'; while (true) { // NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is // new as of July 2011, so do this in a compatible way. Also, "hg log" // and "hg outgoing" don't necessarily show parents (even if given an // explicit template consisting of just the parents token) so we need // to separately execute "hg parents". list($stdout) = execx( '(cd %s && hg parents --style default --rev %s)', $this->getPath(), $against); $parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout); list($p1, $p2) = array_merge($parents_logs, array(null, null)); if ($p1 && !in_array($p1['rev'], $outgoing_revs)) { $against = $p1['rev']; break; } else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) { $against = $p2['rev']; break; } else if ($p1) { $against = $p1['rev']; } else { // This is the case where you have a new repository and the entire // thing is outgoing; Mercurial literally accepts "--rev null" as // meaning "diff against the empty state". $against = 'null'; break; } } $this->relativeCommit = $against; } return $this->relativeCommit; } public function getLocalCommitInformation() { list($info) = execx( '(cd %s && hg log --style default --rev %s..%s --)', $this->getPath(), $this->getRelativeCommit(), $this->getWorkingCopyRevision()); $logs = ArcanistMercurialParser::parseMercurialLog($info); // Get rid of the first log, it's not actually part of the diff. "hg log" // is inclusive, while "hg diff" is exclusive. array_shift($logs); // Expand short hashes (12 characters) to full hashes (40 characters) by // issuing a big "hg log" command. Possibly we should do this with parents // too, but nothing uses them directly at the moment. if ($logs) { $cmd = array(); foreach (ipull($logs, 'rev') as $rev) { $cmd[] = csprintf('--rev %s', $rev); } list($full) = execx( '(cd %s && hg log --template %s %C --)', $this->getPath(), '{node}\\n', implode(' ', $cmd)); $full = explode("\n", trim($full)); foreach ($logs as $key => $dict) { $logs[$key]['rev'] = array_pop($full); } } return $logs; } public function getBlame($path) { list($stdout) = execx( '(cd %s && hg annotate -u -v -c --rev %s -- %s)', $this->getPath(), $this->getRelativeCommit(), $path); $blame = array(); foreach (explode("\n", trim($stdout)) as $line) { if (!strlen($line)) { continue; } $matches = null; $ok = preg_match('/^\s*([^:]+?) [a-f0-9]{12}: (.*)$/', $line, $matches); if (!$ok) { throw new Exception("Unable to parse Mercurial blame line: {$line}"); } $revision = $matches[2]; $author = trim($matches[1]); $blame[] = array($author, $revision); } return $blame; } public function getWorkingCopyStatus() { if (!isset($this->status)) { // A reviewable revision spans multiple local commits in Mercurial, but // there is no way to get file change status across multiple commits, so // just take the entire diff and parse it to figure out what's changed. $diff = $this->getFullMercurialDiff(); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); $status_map = array(); foreach ($changes as $change) { $flags = 0; switch ($change->getType()) { case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_MOVE_HERE: case ArcanistDiffChangeType::TYPE_COPY_HERE: $flags |= self::FLAG_ADDED; break; case ArcanistDiffChangeType::TYPE_CHANGE: case ArcanistDiffChangeType::TYPE_COPY_AWAY: // Check for changes? $flags |= self::FLAG_MODIFIED; break; case ArcanistDiffChangeType::TYPE_DELETE: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: case ArcanistDiffChangeType::TYPE_MULTICOPY: $flags |= self::FLAG_DELETED; break; } $status_map[$change->getCurrentPath()] = $flags; } list($stdout) = execx( '(cd %s && hg status)', $this->getPath()); $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); foreach ($working_status as $path => $status) { if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED) { // If the file is untracked, don't mark it uncommitted. continue; } $status |= self::FLAG_UNCOMMITTED; if (!empty($status_map[$path])) { $status_map[$path] |= $status; } else { $status_map[$path] = $status; } } $this->status = $status_map; } return $this->status; } private function getDiffOptions() { $options = array( '--git', // NOTE: We can't use "--color never" because that flag is provided // by the color extension, which may or may not be enabled. Instead, // set the color mode configuration so that color is disabled regardless // of whether the extension is present or not. '--config color.mode=off', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } public function getRawDiffText($path) { $options = $this->getDiffOptions(); list($stdout) = execx( '(cd %s && hg diff %C --rev %s --rev %s -- %s)', $this->getPath(), $options, $this->getRelativeCommit(), $this->getWorkingCopyRevision(), $path); return $stdout; } public function getFullMercurialDiff() { $options = $this->getDiffOptions(); list($stdout) = execx( '(cd %s && hg diff %C --rev %s --rev %s --)', $this->getPath(), $options, $this->getRelativeCommit(), $this->getWorkingCopyRevision()); return $stdout; } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getRelativeCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision( $path, $this->getWorkingCopyRevision()); } private function getFileDataAtRevision($path, $revision) { list($err, $stdout) = exec_manual( '(cd %s && hg cat --rev %s -- %s)', $this->getPath(), $revision, $path); if ($err) { // Assume this is "no file at revision", i.e. a deleted or added file. return null; } else { return $stdout; } } public function getWorkingCopyRevision() { // In Mercurial, "tip" means the tip of the current branch, not what's in // the working copy. The tip may be ahead of the working copy. We need to // use "hg summary" to figure out what is actually in the working copy. // For instance, "hg up 4 && arc diff" should not show commits 5 and above. // Without arguments, "hg id" shows the current working directory's commit, // and "--debug" expands it to a 40-character hash. list($stdout) = execx( '(cd %s && hg --debug id --id)', $this->getPath()); // Even with "--id", "hg id" will print a trailing "+" after the hash // if the working copy is dirty (has uncommitted changes). We'll explicitly // detect this later by calling getWorkingCopyStatus(); ignore it for now. $stdout = trim($stdout); return rtrim($stdout, '+'); } public function supportsRelativeLocalCommits() { return true; } public function parseRelativeLocalCommit(array $argv) { if (count($argv) == 0) { return; } if (count($argv) != 1) { throw new ArcanistUsageException("Specify only one commit."); } // This does the "hg id" call we need to normalize/validate the revision // identifier. $this->setRelativeCommit(reset($argv)); } public function getAllLocalChanges() { $diff = $this->getFullMercurialDiff(); $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function supportsLocalBranchMerge() { return true; } public function performLocalBranchMerge($branch, $message) { if ($branch) { $err = phutil_passthru( '(cd %s && hg merge --rev %s && hg commit -m %s)', $this->getPath(), $branch, $message); } else { $err = phutil_passthru( '(cd %s && hg merge && hg commit -m %s)', $this->getPath(), $message); } if ($err) { throw new ArcanistUsageException("Merge failed!"); } } public function getFinalizedRevisionMessage() { return "You may now push this commit upstream, as appropriate (e.g. with ". "'hg push' or by printing and faxing it)."; } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { // Try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('hgcm', $commit['rev']); } $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); if ($results) { return $results; } // If we still didn't succeed, try to find revisions by branch name. $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'branches' => array($this->getBranchName()), )); return $results; } } diff --git a/src/repository/api/subversion/ArcanistSubversionAPI.php b/src/repository/api/subversion/ArcanistSubversionAPI.php index 2070734e..d580da09 100644 --- a/src/repository/api/subversion/ArcanistSubversionAPI.php +++ b/src/repository/api/subversion/ArcanistSubversionAPI.php @@ -1,529 +1,529 @@ getSVNStatus() as $path => $mask) { if ($mask & self::FLAG_CONFLICT) { return true; } } return false; } public function getWorkingCopyStatus() { return $this->getSVNStatus(); } public function getSVNBaseRevisions() { if ($this->svnBaseRevisions === null) { $this->getSVNStatus(); } return $this->svnBaseRevisions; } public function getSVNStatus($with_externals = false) { if ($this->svnStatus === null) { list($status) = execx('(cd %s && svn --xml status)', $this->getPath()); $xml = new SimpleXMLElement($status); if (count($xml->target) != 1) { throw new Exception("Expected exactly one XML status target."); } $externals = array(); $files = array(); $target = $xml->target[0]; $this->svnBaseRevisions = array(); foreach ($target->entry as $entry) { $path = (string)$entry['path']; $mask = 0; $props = (string)($entry->{'wc-status'}[0]['props']); $item = (string)($entry->{'wc-status'}[0]['item']); $base = (string)($entry->{'wc-status'}[0]['revision']); $this->svnBaseRevisions[$path] = $base; switch ($props) { case 'none': case 'normal': break; case 'modified': $mask |= self::FLAG_MODIFIED; break; default: throw new Exception("Unrecognized property status '{$props}'."); } switch ($item) { case 'normal': break; case 'external': $mask |= self::FLAG_EXTERNALS; $externals[] = $path; break; case 'unversioned': $mask |= self::FLAG_UNTRACKED; break; case 'obstructed': $mask |= self::FLAG_OBSTRUCTED; break; case 'missing': $mask |= self::FLAG_MISSING; break; case 'added': $mask |= self::FLAG_ADDED; break; case 'replaced': // This is the result of "svn rm"-ing a file, putting another one // in place of it, and then "svn add"-ing the new file. Just treat // this as equivalent to "modified". $mask |= self::FLAG_MODIFIED; break; case 'modified': $mask |= self::FLAG_MODIFIED; break; case 'deleted': $mask |= self::FLAG_DELETED; break; case 'conflicted': $mask |= self::FLAG_CONFLICT; break; case 'incomplete': $mask |= self::FLAG_INCOMPLETE; break; default: throw new Exception("Unrecognized item status '{$item}'."); } // This is new in or around Subversion 1.6. $tree_conflicts = (string)($entry->{'wc-status'}[0]['tree-conflicted']); if ($tree_conflicts) { $mask |= self::FLAG_CONFLICT; } $files[$path] = $mask; } foreach ($files as $path => $mask) { foreach ($externals as $external) { if (!strncmp($path, $external, strlen($external))) { $files[$path] |= self::FLAG_EXTERNALS; } } } $this->svnStatus = $files; } $status = $this->svnStatus; if (!$with_externals) { foreach ($status as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { unset($status[$path]); } } } return $status; } public function getSVNProperty($path, $property) { list($stdout) = execx( 'svn propget %s %s@', $property, $this->getPath($path)); return trim($stdout); } public function getSourceControlPath() { return idx($this->getSVNInfo('/'), 'URL'); } public function getSourceControlBaseRevision() { $info = $this->getSVNInfo('/'); return $info['URL'].'@'.$this->getSVNBaseRevisionNumber(); } public function getSVNBaseRevisionNumber() { if ($this->svnBaseRevisionNumber) { return $this->svnBaseRevisionNumber; } $info = $this->getSVNInfo('/'); return $info['Revision']; } public function overrideSVNBaseRevisionNumber($effective_base_revision) { $this->svnBaseRevisionNumber = $effective_base_revision; return $this; } public function getBranchName() { return 'svn'; } public function buildInfoFuture($path) { if ($path == '/') { // When the root of a working copy is referenced by a symlink and you // execute 'svn info' on that symlink, svn fails. This is a longstanding // bug in svn: // // See http://subversion.tigris.org/issues/show_bug.cgi?id=2305 // // To reproduce, do: // // $ ln -s working_copy working_link // $ svn info working_copy # ok // $ svn info working_link # fails // // Work around this by cd-ing into the directory before executing // 'svn info'. return new ExecFuture( '(cd %s && svn info .)', $this->getPath()); } else { // Note: here and elsewhere we need to append "@" to the path because if // a file has a literal "@" in it, everything after that will be // interpreted as a revision. By appending "@" with no argument, SVN // parses it properly. return new ExecFuture( 'svn info %s@', $this->getPath($path)); } } public function buildDiffFuture($path) { // The "--depth empty" flag prevents us from picking up changes in // children when we run 'diff' against a directory. Specifically, when a // user has added or modified some directory "example/", we want to return // ONLY changes to that directory when given it as a path. If we run // without "--depth empty", svn will give us changes to the directory // itself (such as property changes) and also give us changes to any // files within the directory (basically, implicit recursion). We don't // want that, so prevent recursive diffing. return new ExecFuture( '(cd %s; svn diff --depth empty --diff-cmd diff -x -U%d %s)', $this->getPath(), $this->getDiffLinesOfContext(), $path); } public function primeSVNInfoResult($path, $result) { $this->svnInfoRaw[$path] = $result; return $this; } public function primeSVNDiffResult($path, $result) { $this->svnDiffRaw[$path] = $result; return $this; } public function getSVNInfo($path) { if (empty($this->svnInfo[$path])) { if (empty($this->svnInfoRaw[$path])) { $this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve(); } list($err, $stdout) = $this->svnInfoRaw[$path]; if ($err) { throw new Exception( "Error #{$err} executing svn info against '{$path}'."); } $patterns = array( '/^(URL): (\S+)$/m', '/^(Revision): (\d+)$/m', '/^(Last Changed Author): (\S+)$/m', '/^(Last Changed Rev): (\d+)$/m', '/^(Last Changed Date): (.+) \(.+\)$/m', '/^(Copied From URL): (\S+)$/m', '/^(Copied From Rev): (\d+)$/m', '/^(Repository UUID): (\S+)$/m', ); $result = array(); foreach ($patterns as $pattern) { $matches = null; if (preg_match($pattern, $stdout, $matches)) { $result[$matches[1]] = $matches[2]; } } if (isset($result['Last Changed Date'])) { $result['Last Changed Date'] = strtotime($result['Last Changed Date']); } if (empty($result)) { throw new Exception('Unable to parse SVN info.'); } $this->svnInfo[$path] = $result; } return $this->svnInfo[$path]; } public function getRawDiffText($path) { $status = $this->getSVNStatus(); if (!isset($status[$path])) { return null; } $status = $status[$path]; // Build meaningful diff text for "svn copy" operations. if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $info = $this->getSVNInfo($path); if (!empty($info['Copied From URL'])) { return $this->buildSyntheticAdditionDiff( $path, $info['Copied From URL'], $info['Copied From Rev']); } } // If we run "diff" on a binary file which doesn't have the "svn:mime-type" // of "application/octet-stream", `diff' will explode in a rain of // unhelpful hellfire as it tries to build a textual diff of the two // files. We just fix this inline since it's pretty unambiguous. // TODO: Move this to configuration? $matches = null; if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) { $mime = $this->getSVNProperty($path, 'svn:mime-type'); if ($mime != 'application/octet-stream') { execx( 'svn propset svn:mime-type application/octet-stream %s', $this->getPath($path)); } } if (empty($this->svnDiffRaw[$path])) { $this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve(); } list($err, $stdout, $stderr) = $this->svnDiffRaw[$path]; // Note: GNU Diff returns 2 when SVN hands it binary files to diff and they // differ. This is not an error; it is documented behavior. But SVN isn't // happy about it. SVN will exit with code 1 and return the string below. if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") { throw new Exception( "svn diff returned unexpected error code: $err\n". "stdout: $stdout\n". "stderr: $stderr"); } if ($err == 0 && empty($stdout)) { // If there are no changes, 'diff' exits with no output, but that means // we can not distinguish between empty and unmodified files. Build a // synthetic "diff" without any changes in it. return $this->buildSyntheticUnchangedDiff($path); } return $stdout; } protected function buildSyntheticAdditionDiff($path, $source, $rev) { $type = $this->getSVNProperty($path, 'svn:mime-type'); if ($type == 'application/octet-stream') { return <<getPath($path))) { return null; } $data = Filesystem::readFile($this->getPath($path)); list($orig) = execx('svn cat %s@%s', $source, $rev); $src = new TempFile(); $dst = new TempFile(); Filesystem::writeFile($src, $orig); Filesystem::writeFile($dst, $data); list($err, $diff) = exec_manual( 'diff -L a/%s -L b/%s -U%d %s %s', str_replace($this->getSourceControlPath().'/', '', $source), $path, $this->getDiffLinesOfContext(), $src, $dst); if ($err == 1) { // 1 means there are differences. return <<buildSyntheticUnchangedDiff($path); } } protected function buildSyntheticUnchangedDiff($path) { $full_path = $this->getPath($path); if (is_dir($full_path)) { return null; } $data = Filesystem::readFile($full_path); $lines = explode("\n", $data); $len = count($lines); foreach ($lines as $key => $line) { $lines[$key] = ' '.$line; } $lines = implode("\n", $lines); return <<getPath(), $path); $stdout = trim($stdout); if (!strlen($stdout)) { // Empty file. return $blame; } foreach (explode("\n", $stdout) as $line) { $m = array(); if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) { throw new Exception("Bad blame? `{$line}'"); } $revision = $m[1]; $author = $m[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { // SVN issues warnings for nonexistent paths, directories, etc., but still // returns no error code. However, for new paths in the working copy it // fails. Assume that failure means the original file does not exist. list($err, $stdout) = exec_manual( '(cd %s && svn cat %s@)', $this->getPath(), $path); if ($err) { return null; } return $stdout; } public function getCurrentFileData($path) { $full_path = $this->getPath($path); if (Filesystem::pathExists($full_path)) { return Filesystem::readFile($full_path); } return null; } public function getRepositorySVNUUID() { $info = $this->getSVNInfo('/'); return $info['Repository UUID']; } public function getLocalCommitInformation() { return null; } public function supportsRelativeLocalCommits() { return false; } public function getWorkingCopyRevision() { return $this->getSourceControlBaseRevision(); } public function supportsLocalBranchMerge() { return false; } public function getFinalizedRevisionMessage() { // In other VCSes we give push instructions here, but it never makes sense // in SVN. return "Done."; } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { // We don't have much to go on in SVN, look for revisions that came from // this directory. $results = $conduit->callMethodSynchronous( 'differential.query', $query); foreach ($results as $key => $result) { if ($result['sourcePath'] != $this->getPath()) { unset($results[$key]); } } return $results; } } diff --git a/src/repository/hookapi/subversion/ArcanistSubversionHookAPI.php b/src/repository/hookapi/subversion/ArcanistSubversionHookAPI.php index 0982f033..2381df43 100644 --- a/src/repository/hookapi/subversion/ArcanistSubversionHookAPI.php +++ b/src/repository/hookapi/subversion/ArcanistSubversionHookAPI.php @@ -1,43 +1,43 @@ root = $root; $this->transaction = $transaction; $this->repository = $repository; } public function getCurrentFileData($path) { list($err, $file) = exec_manual( 'svnlook cat --transaction %s %s %s', $this->transaction, $this->repository, $this->root . "/$path"); return ($err? null : $file); } } diff --git a/src/unit/engine/phutil/PhutilUnitTestEngine.php b/src/unit/engine/phutil/PhutilUnitTestEngine.php index f4686228..7f8f5199 100644 --- a/src/unit/engine/phutil/PhutilUnitTestEngine.php +++ b/src/unit/engine/phutil/PhutilUnitTestEngine.php @@ -1,135 +1,135 @@ getPaths() as $path) { $library_root = phutil_get_library_root_for_path($path); if (!$library_root) { continue; } $library_name = phutil_get_library_name_for_root($library_root); $path = Filesystem::resolvePath($path); if (!is_dir($path)) { $path = dirname($path); } if ($path == $library_root) { continue; } $library_path = Filesystem::readablePath($path, $library_root); do { // Add the module and all parent modules as affected modules, which // means we'll look for __tests__ to run here and in any containing // module. $affected_modules[$library_name.':'.$library_path] = array( 'name' => $library_name, 'root' => $library_root, 'path' => $library_path, ); $library_path = dirname($library_path); } while ($library_path != '.'); } $tests = array(); foreach ($affected_modules as $library_info) { $library_name = $library_info['name']; $library_root = $library_info['root']; $module = $library_info['path']; if (basename($module) == '__tests__') { // Okay, this is a __tests__ module. } else { $exists = $bootloader->moduleExists( $library_name, $module.'/__tests__'); if ($exists) { // This is a module which has a __tests__ module in it. $module .= '/__tests__'; } else { // Look for a parent named __tests__. $rpos = strrpos($module, '/__tests__'); if ($rpos === false) { // No tests to run since there is no child or parent module named // __tests__. continue; } // Select the parent named __tests__. $module = substr($module, 0, $rpos + strlen('/__tests__')); } } $module_key = $library_name.':'.$module; $tests[$module_key] = array( 'library' => $library_name, 'root' => $library_root, 'module' => $module, ); } if (!$tests) { throw new ArcanistNoEffectException("No tests to run."); } $run_tests = array(); foreach ($tests as $test) { $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setLibrary($test['library']) ->setModule($test['module']) ->setAncestorClass('ArcanistPhutilTestCase') ->selectAndLoadSymbols(); foreach ($symbols as $symbol) { $run_tests[$symbol['name']] = true; } } $run_tests = array_keys($run_tests); if (!$run_tests) { throw new ArcanistNoEffectException( "No tests to run. You may need to rebuild the phutil library map."); } $results = array(); foreach ($run_tests as $test_class) { PhutilSymbolLoader::loadClass($test_class); $test_case = newv($test_class, array()); $results[] = $test_case->run(); } if ($results) { $results = call_user_func_array('array_merge', $results); } return $results; } } diff --git a/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php b/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php index 9b55c847..b8d45bc2 100644 --- a/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php +++ b/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php @@ -1,89 +1,89 @@ assertEqual( 1, self::$allTestsCounter, 'Expect willRunTests() has been called once.'); self::$allTestsCounter--; $actual_test_count = 2; $this->assertEqual( $actual_test_count, count(self::$distinctWillRunTests), 'Expect willRunOneTest() was called once for each test.'); $this->assertEqual( $actual_test_count, count(self::$distinctDidRunTests), 'Expect didRunOneTest() was called once for each test.'); $this->assertEqual( self::$distinctWillRunTests, self::$distinctDidRunTests, 'Expect same tests had pre- and post-run callbacks invoked.'); } public function __destruct() { if (self::$allTestsCounter !== 0) { throw new Exception( "didRunTests() was not called correctly after tests completed!"); } } protected function willRunOneTest($test) { self::$distinctWillRunTests[$test] = true; self::$oneTestCounter++; } protected function didRunOneTest($test) { $this->assertEqual( 1, self::$oneTestCounter, 'Expect willRunOneTest depth to be one.'); self::$distinctDidRunTests[$test] = true; self::$oneTestCounter--; } public function testPass() { $this->assertEqual(1, 1, 'This test is expected to pass.'); } public function testFail() { $this->assertFailure('This test is expected to fail.'); } } diff --git a/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php b/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php index 1c80ea4c..5d0c76e7 100644 --- a/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php +++ b/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php @@ -1,24 +1,24 @@ name = $name; return $this; } public function getName() { return $this->name; } public function setResult($result) { $this->result = $result; return $this; } public function getResult() { return $this->result; } public function setDuration($duration) { $this->duration = $duration; return $this; } public function getDuration() { return $this->duration; } public function setUserData($user_data) { $this->userData = $user_data; return $this; } public function getUserData() { return $this->userData; } } diff --git a/src/workflow/amend/ArcanistAmendWorkflow.php b/src/workflow/amend/ArcanistAmendWorkflow.php index e2cb9844..fa0bc7c7 100644 --- a/src/workflow/amend/ArcanistAmendWorkflow.php +++ b/src/workflow/amend/ArcanistAmendWorkflow.php @@ -1,189 +1,189 @@ array( 'help' => "Show the amended commit message, without modifying the working copy." ), 'revision' => array( 'param' => 'revision_id', 'help' => "Amend a specific revision. If you do not specify a revision, ". "arc will look in the commit message at HEAD.", ), ); } public function run() { $is_show = $this->getArgument('show'); $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { throw new ArcanistUsageException( "You may only run 'arc amend' in a git working copy."); } if (!$is_show) { if ($this->isHistoryImmutable()) { throw new ArcanistUsageException( "This project is marked as adhering to a conservative history ". "mutability doctrine (having an immutable local history), which ". "precludes amending commit messages. You can use 'arc merge' to ". "merge feature branches instead."); } if ($repository_api->getUncommittedChanges()) { throw new ArcanistUsageException( "You have uncommitted changes in this branch. Stage and commit (or ". "revert) them before proceeding."); } } $revision_id = null; if ($this->getArgument('revision')) { $revision_id = $this->normalizeRevisionID($this->getArgument('revision')); } $in_working_copy = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-any', )); $in_working_copy = ipull($in_working_copy, null, 'id'); if (!$revision_id) { if (count($in_working_copy) == 0) { throw new ArcanistUsageException( "No revision specified with '--revision', and no revisions found ". "in the working copy. Use '--revision ' to specify which ". "revision you want to amend."); } else if (count($in_working_copy) > 1) { $message = "More than one revision was found in the working copy:\n". $this->renderRevisionList($in_working_copy)."\n". "Use '--revision ' to specify which revision you want to ". "amend."; throw new ArcanistUsageException($message); } else { $revision_id = key($in_working_copy); } } $conduit = $this->getConduit(); try { $message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => false, ) ); } catch (ConduitClientException $ex) { if (strpos($ex->getMessage(), 'ERR_NOT_FOUND') === false) { throw $ex; } else { throw new ArcanistUsageException( "Revision D{$revision_id} does not exist." ); } } $revision = $conduit->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (empty($revision)) { throw new Exception( "Failed to lookup information for 'D{$revision_id}'!"); } $revision = head($revision); $revision_title = $revision['title']; if (!$is_show) { if ($revision_id && empty($in_working_copy[$revision_id])) { $ok = phutil_console_confirm( "The revision 'D{$revision_id}' does not appear to be in the ". "working copy. Are you sure you want to amend HEAD with the ". "commit message for 'D{$revision_id}: {$revision_title}'?"); if (!$ok) { throw new ArcanistUserAbortException(); } } } if ($is_show) { echo $message."\n"; } else { echo phutil_console_format( "Amending commit message to reflect revision **%s**.\n", "D{$revision_id}: {$revision_title}"); $repository_api->amendGitHeadCommit($message); $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array( '--finalize', $revision_id, )); $mark_workflow->run(); } return 0; } protected function getSupportedRevisionControlSystems() { return array('git'); } } diff --git a/src/workflow/base/ArcanistBaseWorkflow.php b/src/workflow/base/ArcanistBaseWorkflow.php index 7a459861..c5182058 100644 --- a/src/workflow/base/ArcanistBaseWorkflow.php +++ b/src/workflow/base/ArcanistBaseWorkflow.php @@ -1,1003 +1,1004 @@ conduit) { throw new Exception( "You can not change the Conduit URI after a conduit is already open."); } $this->conduitURI = $conduit_uri; return $this; } /** * Returns the URI the conduit connection within the workflow uses. * * @return string * @task conduit */ final public function getConduitURI() { return $this->conduitURI; } /** * Open a conduit channel to the server which was previously configured by * calling @{method:setConduitURI}. Arcanist will do this automatically if * the workflow returns ##true## from @{method:requiresConduit}, or you can * later upgrade a workflow and build a conduit by invoking it manually. * * You must establish a conduit before you can make conduit calls. * * NOTE: You must call @{method:setConduitURI} before you can call this * method. * * @return this * @task conduit */ final public function establishConduit() { if ($this->conduit) { return $this; } if (!$this->conduitURI) { throw new Exception( "You must specify a Conduit URI with setConduitURI() before you can ". "establish a conduit."); } $this->conduit = new ConduitClient($this->conduitURI); return $this; } /** * Set credentials which will be used to authenticate against Conduit. These * credentials can then be used to establish an authenticated connection to * conduit by calling @{method:authenticateConduit}. Arcanist sets some * defaults for all workflows regardless of whether or not they return true * from @{method:requireAuthentication}, based on the ##~/.arcrc## and * ##.arcconf## files if they are present. Thus, you can generally upgrade a * workflow which does not require authentication into an authenticated * workflow by later invoking @{method:requireAuthentication}. You should not * normally need to call this method unless you are specifically overriding * the defaults. * * NOTE: You can not call this method after calling * @{method:authenticateConduit}. * * @param dict A credential dictionary, see @{method:authenticateConduit}. * @return this * @task conduit */ final public function setConduitCredentials(array $credentials) { if ($this->conduitAuthenticated) { throw new Exception( "You may not set new credentials after authenticating conduit."); } $this->conduitCredentials = $credentials; return $this; } /** * Open and authenticate a conduit connection to a Phabricator server using * provided credentials. Normally, Arcanist does this for you automatically * when you return true from @{method:requiresAuthentication}, but you can * also upgrade an existing workflow to one with an authenticated conduit * by invoking this method manually. * * You must authenticate the conduit before you can make authenticated conduit * calls (almost all calls require authentication). * * This method uses credentials provided via @{method:setConduitCredentials} * to authenticate to the server: * * - ##user## (required) The username to authenticate with. * - ##certificate## (required) The Conduit certificate to use. * - ##description## (optional) Description of the invoking command. * * Successful authentication allows you to call @{method:getUserPHID} and * @{method:getUserName}, as well as use the client you access with * @{method:getConduit} to make authenticated calls. * * NOTE: You must call @{method:setConduitURI} and * @{method:setConduitCredentials} before you invoke this method. * * @return this * @task conduit */ final public function authenticateConduit() { if ($this->conduitAuthenticated) { return $this; } $this->establishConduit(); $credentials = $this->conduitCredentials; if (!$credentials) { throw new Exception( "Set conduit credentials with setConduitCredentials() before ". "authenticating conduit!"); } if (empty($credentials['user']) || empty($credentials['certificate'])) { throw new Exception( "Credentials must include a 'user' and a 'certificate'."); } $description = idx($credentials, 'description', ''); $user = $credentials['user']; $certificate = $credentials['certificate']; try { $connection = $this->getConduit()->callMethodSynchronous( 'conduit.connect', array( 'client' => 'arc', 'clientVersion' => 3, 'clientDescription' => php_uname('n').':'.$description, 'user' => $user, 'certificate' => $certificate, 'host' => $this->conduitURI, )); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' || $ex->getErrorCode() == 'ERR-INVALID-USER') { $conduit_uri = $this->conduitURI; $message = "\n". phutil_console_format( "YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR"). "\n\n". phutil_console_format( " To do this, run: **arc install-certificate**"). "\n\n". "The server '{$conduit_uri}' rejected your request:". "\n". $ex->getMessage(); throw new ArcanistUsageException($message); } else { throw $ex; } } $this->userName = $user; $this->userPHID = $connection['userPHID']; $this->conduitAuthenticated = true; return $this; } /** * Override this to return true if your workflow requires a conduit channel. * Arc will build the channel for you before your workflow executes. This * implies that you only need an unauthenticated channel; if you need * authentication, override @{method:requiresAuthentication}. * * @return bool True if arc should build a conduit channel before running * the workflow. * @task conduit */ public function requiresConduit() { return false; } /** * Override this to return true if your workflow requires an authenticated * conduit channel. This implies that it requires a conduit. Arc will build * and authenticate the channel for you before the workflow executes. * * @return bool True if arc should build an authenticated conduit channel * before running the workflow. * @task conduit */ public function requiresAuthentication() { return false; } /** * Returns the PHID for the user once they've authenticated via Conduit. * * @return phid Authenticated user PHID. * @task conduit */ final public function getUserPHID() { if (!$this->userPHID) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires authentication, override ". "requiresAuthentication() to return true."); } return $this->userPHID; } /** * Deprecated. See @{method:getUserPHID}. * * @deprecated */ final public function getUserGUID() { phutil_deprecated( 'ArcanistBaseWorkflow::getUserGUID', 'This method has been renamed to getUserPHID().'); return $this->getUserPHID(); } /** * Return the username for the user once they've authenticated via Conduit. * * @return string Authenticated username. * @task conduit */ final public function getUserName() { return $this->userName; } /** * Get the established @{class@libphutil:ConduitClient} in order to make * Conduit method calls. Before the client is available it must be connected, * either implicitly by making @{method:requireConduit} or * @{method:requireAuthentication} return true, or explicitly by calling * @{method:establishConduit} or @{method:authenticateConduit}. * * @return @{class@libphutil:ConduitClient} Live conduit client. * @task conduit */ final public function getConduit() { if (!$this->conduit) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a Conduit, override ". "requiresConduit() to return true."); } return $this->conduit; } public function setArcanistConfiguration($arcanist_configuration) { $this->arcanistConfiguration = $arcanist_configuration; return $this; } public function getArcanistConfiguration() { return $this->arcanistConfiguration; } public function getCommandHelp() { return get_class($this).": Undocumented"; } public function requiresWorkingCopy() { return false; } public function requiresRepositoryAPI() { return false; } public function setCommand($command) { $this->command = $command; return $this; } public function getCommand() { return $this->command; } public function getArguments() { return array(); } public function setWorkingDirectory($working_directory) { $this->workingDirectory = $working_directory; return $this; } public function getWorkingDirectory() { return $this->workingDirectory; } private function setParentWorkflow($parent_workflow) { $this->parentWorkflow = $parent_workflow; return $this; } protected function getParentWorkflow() { return $this->parentWorkflow; } public function buildChildWorkflow($command, array $argv) { $arc_config = $this->getArcanistConfiguration(); $workflow = $arc_config->buildWorkflow($command); $workflow->setParentWorkflow($this); $workflow->setCommand($command); if ($this->repositoryAPI) { $workflow->setRepositoryAPI($this->repositoryAPI); } if ($this->userPHID) { $workflow->userPHID = $this->getUserPHID(); $workflow->userName = $this->getUserName(); } if ($this->conduit) { $workflow->conduit = $this->conduit; } if ($this->workingCopy) { $workflow->setWorkingCopy($this->workingCopy); } $workflow->setArcanistConfiguration($arc_config); $workflow->parseArguments(array_values($argv)); return $workflow; } public function getArgument($key, $default = null) { $args = $this->arguments; if (!array_key_exists($key, $args)) { return $default; } return $args[$key]; } final public function getCompleteArgumentSpecification() { $spec = $this->getArguments(); $arc_config = $this->getArcanistConfiguration(); $command = $this->getCommand(); $spec += $arc_config->getCustomArgumentsForCommand($command); return $spec; } public function parseArguments(array $args) { $spec = $this->getCompleteArgumentSpecification(); $dict = array(); $more_key = null; if (!empty($spec['*'])) { $more_key = $spec['*']; unset($spec['*']); $dict[$more_key] = array(); } $short_to_long_map = array(); foreach ($spec as $long => $options) { if (!empty($options['short'])) { $short_to_long_map[$options['short']] = $long; } } $more = array(); for ($ii = 0; $ii < count($args); $ii++) { $arg = $args[$ii]; $arg_name = null; $arg_key = null; if ($arg == '--') { $more = array_merge( $more, array_slice($args, $ii + 1)); break; } else if (!strncmp($arg, '--', 2)) { $arg_key = substr($arg, 2); if (!array_key_exists($arg_key, $spec)) { throw new ArcanistUsageException( "Unknown argument '{$arg_key}'. Try 'arc help'."); } } else if (!strncmp($arg, '-', 1)) { $arg_key = substr($arg, 1); if (empty($short_to_long_map[$arg_key])) { throw new ArcanistUsageException( "Unknown argument '{$arg_key}'. Try 'arc help'."); } $arg_key = $short_to_long_map[$arg_key]; } else { $more[] = $arg; continue; } $options = $spec[$arg_key]; if (empty($options['param'])) { $dict[$arg_key] = true; } else { if ($ii == count($args) - 1) { throw new ArcanistUsageException( "Option '{$arg}' requires a parameter."); } $dict[$arg_key] = $args[$ii + 1]; $ii++; } } if ($more) { if ($more_key) { $dict[$more_key] = $more; } else { $example = reset($more); throw new ArcanistUsageException( "Unrecognized argument '{$example}'. Try 'arc help'."); } } foreach ($dict as $key => $value) { if (empty($spec[$key]['conflicts'])) { continue; } foreach ($spec[$key]['conflicts'] as $conflict => $more) { if (isset($dict[$conflict])) { if ($more) { $more = ': '.$more; } else { $more = '.'; } // TODO: We'll always display these as long-form, when the user might // have typed them as short form. throw new ArcanistUsageException( "Arguments '--{$key}' and '--{$conflict}' are mutually exclusive". $more); } } } $this->arguments = $dict; $this->didParseArguments(); return $this; } protected function didParseArguments() { // Override this to customize workflow argument behavior. } public function getWorkingCopy() { if (!$this->workingCopy) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a working copy, override ". "requiresWorkingCopy() to return true."); } return $this->workingCopy; } public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; return $this; } public function setRepositoryAPI($api) { $this->repositoryAPI = $api; return $this; } public function getRepositoryAPI() { if (!$this->repositoryAPI) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a Repository API, override ". "requiresRepositoryAPI() to return true."); } return $this->repositoryAPI; } protected function shouldRequireCleanUntrackedFiles() { return empty($this->arguments['allow-untracked']); } public function requireCleanWorkingCopy() { $api = $this->getRepositoryAPI(); $working_copy_desc = phutil_console_format( " Working copy: __%s__\n\n", $api->getPath()); $untracked = $api->getUntrackedChanges(); if ($this->shouldRequireCleanUntrackedFiles()) { if (!empty($untracked)) { echo "You have untracked files in this working copy.\n\n". $working_copy_desc. " Untracked files in working copy:\n". " ".implode("\n ", $untracked)."\n\n"; if ($api instanceof ArcanistGitAPI) { echo phutil_console_wrap( "Since you don't have '.gitignore' rules for these files and have ". "not listed them in '.git/info/exclude', you may have forgotten ". "to 'git add' them to your commit."); } else if ($api instanceof ArcanistSubversionAPI) { echo phutil_console_wrap( "Since you don't have 'svn:ignore' rules for these files, you may ". "have forgotten to 'svn add' them."); } else if ($api instanceof ArcanistMercurialAPI) { echo phutil_console_wrap( "Since you don't have '.hgignore' rules for these files, you ". "may have forgotten to 'hg add' them to your commit."); } $prompt = "Do you want to continue without adding these files?"; if (!phutil_console_confirm($prompt, $default_no = false)) { throw new ArcanistUserAbortException(); } } } $incomplete = $api->getIncompleteChanges(); if ($incomplete) { throw new ArcanistUsageException( "You have incompletely checked out directories in this working copy. ". "Fix them before proceeding.\n\n". $working_copy_desc. " Incomplete directories in working copy:\n". " ".implode("\n ", $incomplete)."\n\n". "You can fix these paths by running 'svn update' on them."); } $conflicts = $api->getMergeConflicts(); if ($conflicts) { throw new ArcanistUsageException( "You have merge conflicts in this working copy. Resolve merge ". "conflicts before proceeding.\n\n". $working_copy_desc. " Conflicts in working copy:\n". " ".implode("\n ", $conflicts)."\n"); } $unstaged = $api->getUnstagedChanges(); if ($unstaged) { throw new ArcanistUsageException( "You have unstaged changes in this working copy. Stage and commit (or ". "revert) them before proceeding.\n\n". $working_copy_desc. " Unstaged changes in working copy:\n". " ".implode("\n ", $unstaged)."\n"); } $uncommitted = $api->getUncommittedChanges(); if ($uncommitted) { throw new ArcanistUsageException( "You have uncommitted changes in this branch. Commit (or revert) them ". "before proceeding.\n\n". $working_copy_desc. " Uncommitted changes in working copy\n". " ".implode("\n ", $uncommitted)."\n"); } } protected function chooseRevision( array $revision_data, $revision_id, $prompt = null) { $revisions = array(); foreach ($revision_data as $data) { $ref = ArcanistDifferentialRevisionRef::newFromDictionary($data); $revisions[$ref->getID()] = $ref; } if ($revision_id) { $revision_id = $this->normalizeRevisionID($revision_id); if (empty($revisions[$revision_id])) { throw new ArcanistChooseInvalidRevisionException(); } return $revisions[$revision_id]; } if (!count($revisions)) { throw new ArcanistChooseNoRevisionsException(); } $repository_api = $this->getRepositoryAPI(); $candidates = array(); $cur_path = $repository_api->getPath(); foreach ($revisions as $revision) { $source_path = $revision->getSourcePath(); if ($source_path == $cur_path) { $candidates[] = $revision; } } if (count($candidates) == 1) { $candidate = reset($candidates); $revision_id = $candidate->getID(); } if ($revision_id) { return $revisions[$revision_id]; } $revision_indexes = array_keys($revisions); echo "\n"; $ii = 1; foreach ($revisions as $revision) { echo ' ['.$ii++.'] D'.$revision->getID().' '.$revision->getName()."\n"; } while (true) { $id = phutil_console_prompt($prompt); $id = trim(strtoupper($id), 'D'); if (isset($revisions[$id])) { return $revisions[$id]; } if (isset($revision_indexes[$id - 1])) { return $revisions[$revision_indexes[$id - 1]]; } } } protected function loadDiffBundleFromConduit( ConduitClient $conduit, $diff_id) { return $this->loadBundleFromConduit( $conduit, array( 'diff_id' => $diff_id, )); } protected function loadRevisionBundleFromConduit( ConduitClient $conduit, $revision_id) { return $this->loadBundleFromConduit( $conduit, array( 'revision_id' => $revision_id, )); } private function loadBundleFromConduit( ConduitClient $conduit, $params) { $future = $conduit->callMethod('differential.getdiff', $params); $diff = $future->resolve(); $changes = array(); foreach ($diff['changes'] as $changedict) { $changes[] = ArcanistDiffChange::newFromDictionary($changedict); } $bundle = ArcanistBundle::newFromChanges($changes); $bundle->setConduit($conduit); $bundle->setProjectID($diff['projectName']); $bundle->setBaseRevision($diff['sourceControlBaseRevision']); $bundle->setRevisionID($diff['revisionID']); return $bundle; } /** * Return a list of lines changed by the current diff, or ##null## if the * change list is meaningless (for example, because the path is a directory * or binary file). * * @param string Path within the repository. * @param string Change selection mode (see ArcanistDiffHunk). * @return list|null List of changed line numbers, or null to indicate that * the path is not a line-oriented text file. */ protected function getChangedLines($path, $mode) { $repository_api = $this->getRepositoryAPI(); $full_path = $repository_api->getPath($path); if (is_dir($full_path)) { return null; } $change = $this->getChange($path); if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) { return null; } $lines = $change->getChangedLines($mode); return array_keys($lines); } private function getChange($path) { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { // NOTE: In SVN, we don't currently support a "get all local changes" // operation, so special case it. if (empty($this->changeCache[$path])) { $diff = $repository_api->getRawDiffText($path); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); if (count($changes) != 1) { throw new Exception("Expected exactly one change."); } $this->changeCache[$path] = reset($changes); } } else if ($repository_api->supportsRelativeLocalCommits()) { if (empty($this->changeCache)) { $changes = $repository_api->getAllLocalChanges(); foreach ($changes as $change) { $this->changeCache[$change->getCurrentPath()] = $change; } } } else { throw new Exception("Missing VCS support."); } if (empty($this->changeCache[$path])) { if ($repository_api instanceof ArcanistGitAPI) { // This can legitimately occur under git if you make a change, "git // commit" it, and then revert the change in the working copy and run // "arc lint". $change = new ArcanistDiffChange(); $change->setCurrentPath($path); return $change; } else { throw new Exception( "Trying to get change for unchanged path '{$path}'!"); } } return $this->changeCache[$path]; } final public function willRunWorkflow() { $spec = $this->getCompleteArgumentSpecification(); foreach ($this->arguments as $arg => $value) { if (empty($spec[$arg])) { continue; } $options = $spec[$arg]; if (!empty($options['supports'])) { $system_name = $this->getRepositoryAPI()->getSourceControlSystemName(); if (!in_array($system_name, $options['supports'])) { $extended_info = null; if (!empty($options['nosupport'][$system_name])) { $extended_info = ' '.$options['nosupport'][$system_name]; } throw new ArcanistUsageException( "Option '--{$arg}' is not supported under {$system_name}.". $extended_info); } } } } protected function normalizeRevisionID($revision_id) { return ltrim(strtoupper($revision_id), 'D'); } protected function shouldShellComplete() { return true; } protected function getShellCompletions(array $argv) { return array(); } protected function getSupportedRevisionControlSystems() { return array('any'); } protected function getPassthruArgumentsAsMap($command) { $map = array(); foreach ($this->getCompleteArgumentSpecification() as $key => $spec) { if (!empty($spec['passthru'][$command])) { if (isset($this->arguments[$key])) { $map[$key] = $this->arguments[$key]; } } } return $map; } protected function getPassthruArgumentsAsArgv($command) { $spec = $this->getCompleteArgumentSpecification(); $map = $this->getPassthruArgumentsAsMap($command); $argv = array(); foreach ($map as $key => $value) { $argv[] = '--'.$key; if (!empty($spec[$key]['param'])) { $argv[] = $value; } } return $argv; } public static function getUserConfigurationFileLocation() { return getenv('HOME').'/.arcrc'; } public static function readUserConfigurationFile() { $user_config = array(); $user_config_path = self::getUserConfigurationFileLocation(); if (Filesystem::pathExists($user_config_path)) { $mode = fileperms($user_config_path); if (!$mode) { throw new Exception("Unable to get perms of '{$user_config_path}'!"); } if ($mode & 0177) { // Mode should allow only owner access. $prompt = "File permissions on your ~/.arcrc are too open. ". "Fix them by chmod'ing to 600?"; if (!phutil_console_confirm($prompt, $default_no = false)) { throw new ArcanistUsageException("Set ~/.arcrc to file mode 600."); } execx('chmod 600 %s', $user_config_path); } $user_config_data = Filesystem::readFile($user_config_path); $user_config = json_decode($user_config_data, true); if (!is_array($user_config)) { throw new ArcanistUsageException( "Your '~/.arcrc' file is not a valid JSON file."); } } return $user_config; } /** * Write a message to stderr so that '--json' flags or stdout which is meant * to be piped somewhere aren't disrupted. * * @param string Message to write to stderr. * @return void */ protected function writeStatusMessage($msg) { file_put_contents('php://stderr', $msg); } protected function isHistoryImmutable() { $working_copy = $this->getWorkingCopy(); return ($working_copy->getConfig('immutable_history') === true); } /** * Workflows like 'lint' and 'unit' operate on a list of working copy paths. * The user can either specify the paths explicitly ("a.js b.php"), or by * specfifying a revision ("--rev a3f10f1f") to select all paths modified * since that revision, or by omitting both and letting arc choose the * default relative revision. * * This method takes the user's selections and returns the paths that the * workflow should act upon. * * @param list List of explicitly provided paths. * @param string|null Revision name, if provided. * @return list List of paths the workflow should act on. */ protected function selectPathsForWorkflow(array $paths, $rev) { if ($paths) { $working_copy = $this->getWorkingCopy(); foreach ($paths as $key => $path) { $full_path = Filesystem::resolvePath($path); if (!Filesystem::pathExists($full_path)) { throw new ArcanistUsageException("Path '{$path}' does not exist!"); } $relative_path = Filesystem::readablePath( $full_path, $working_copy->getProjectRoot()); $paths[$key] = $relative_path; } } else { $repository_api = $this->getRepositoryAPI(); if ($rev) { $repository_api->parseRelativeLocalCommit(array($rev)); } $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $flags) { if ($flags & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } $paths = array_keys($paths); } return array_values($paths); } protected function renderRevisionList(array $revisions) { $list = array(); foreach ($revisions as $revision) { $list[] = ' - D'.$revision['id'].': '.$revision['title']."\n"; } return implode('', $list); } } diff --git a/src/workflow/branch/ArcanistBranchWorkflow.php b/src/workflow/branch/ArcanistBranchWorkflow.php index fa6767f8..cc56998e 100644 --- a/src/workflow/branch/ArcanistBranchWorkflow.php +++ b/src/workflow/branch/ArcanistBranchWorkflow.php @@ -1,175 +1,175 @@ array( 'help' => "Include committed and abandoned revisions", ), 'by-status' => array( 'help' => 'Group output by revision status.', ), ); } public function run() { $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { throw new ArcanistUsageException( "arc branch is only supported under git." ); } $this->branches = BranchInfo::loadAll($repository_api); $all_revisions = array_unique( array_filter(mpull($this->branches, 'getRevisionId'))); $revision_status = $this->loadDifferentialStatuses($all_revisions); $owner = $repository_api->getRepositoryOwner(); foreach ($this->branches as $branch) { if ($branch->getCommitAuthor() != $owner) { $branch->setStatus('Not Yours'); continue; } $rev_id = $branch->getRevisionID(); if ($rev_id) { $status = idx($revision_status, $rev_id, 'Unknown Status'); $branch->setStatus($status); } else { $branch->setStatus('No Revision'); } } if (!$this->getArgument('view-all')) { $this->filterOutFinished(); } $this->printInColumns(); } /** * Makes a conduit call to differential to find out revision statuses * based on their IDs */ private function loadDifferentialStatuses($rev_ids) { $conduit = $this->getConduit(); $revision_future = $conduit->callMethod( 'differential.find', array( 'guids' => $rev_ids, 'query' => 'revision-ids', )); $revisions = array(); foreach ($revision_future->resolve() as $revision_dict) { $revisions[] = ArcanistDifferentialRevisionRef::newFromDictionary( $revision_dict); } $statuses = mpull($revisions, 'getStatusName', 'getId'); return $statuses; } /** * Removes the branches with status either committed or abandoned. */ private function filterOutFinished() { foreach ($this->branches as $id => $branch) { if ($branch->isCurrentHead() ) { continue; //never filter the current branch } $status = $branch->getStatus(); if ($status == 'Committed' || $status == 'Abandoned') { unset($this->branches[$id]); } } } public function printInColumns() { $longest_name = 0; $longest_status = 0; foreach ($this->branches as $branch) { $longest_name = max(strlen($branch->getFormattedName()), $longest_name); $longest_status = max(strlen($branch->getStatus()), $longest_status); } if ($this->getArgument('by-status')) { $by_status = mgroup($this->branches, 'getStatus'); foreach (array('Accepted', 'Needs Revision', 'Needs Review', 'No Revision') as $status) { $branches = idx($by_status, $status); if (!$branches) { continue; } echo reset($branches)->getFormattedStatus()."\n"; foreach ($branches as $branch) { $name_markdown = $branch->getFormattedName(); $subject = $branch->getCommitDisplayName(); $name_markdown = str_pad($name_markdown, $longest_name + 4, ' '); echo " $name_markdown $subject\n"; } } } else { foreach ($this->branches as $branch) { $name_markdown = $branch->getFormattedName(); $status_markdown = $branch->getFormattedStatus(); $subject = $branch->getCommitDisplayName(); $subject_pad = $longest_status - strlen($branch->getStatus()) + 4; $name_markdown = str_pad($name_markdown, $longest_name + 4, ' '); $subject = str_pad($subject, strlen($subject) + $subject_pad, ' ', STR_PAD_LEFT); echo "$name_markdown $status_markdown $subject\n"; } } } } diff --git a/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php b/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php index 7a225dd3..713e4840 100644 --- a/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php +++ b/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php @@ -1,99 +1,99 @@ 'method', ); } public function shouldShellComplete() { return false; } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function run() { $method = $this->getArgument('method', array()); if (count($method) !== 1) { throw new ArcanistUsageException( "Provide exactly one Conduit method name."); } $method = reset($method); $params = @file_get_contents('php://stdin'); $params = json_decode($params, true); if (!is_array($params)) { throw new ArcanistUsageException( "Provide method parameters on stdin as a JSON blob."); } $error = null; $error_message = null; try { $result = $this->getConduit()->callMethodSynchronous( $method, $params); } catch (ConduitClientException $ex) { $error = $ex->getErrorCode(); $error_message = $ex->getMessage(); $result = null; } echo json_encode(array( 'error' => $error, 'errorMessage' => $error_message, 'response' => $result, ))."\n"; return 0; } } diff --git a/src/workflow/commit/ArcanistCommitWorkflow.php b/src/workflow/commit/ArcanistCommitWorkflow.php index f6839881..dca10af6 100644 --- a/src/workflow/commit/ArcanistCommitWorkflow.php +++ b/src/workflow/commit/ArcanistCommitWorkflow.php @@ -1,342 +1,342 @@ revisionID; } public function getArguments() { return array( 'show' => array( 'help' => "Show the command which would be issued, but do not actually ". "commit anything." ), 'revision' => array( 'param' => 'revision_id', 'help' => "Commit a specific revision. If you do not specify a revision, ". "arc will look for committable revisions.", ) ); } public function run() { $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistSubversionAPI)) { throw new ArcanistUsageException( "'arc commit' is only supported under svn."); } $revision_id = $this->normalizeRevisionID($this->getArgument('revision')); if (!$revision_id) { $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-accepted', )); if (count($revisions) == 0) { throw new ArcanistUsageException( "Unable to identify the revision in the working copy. Use ". "'--revision ' to select a revision."); } else if (count($revisions) > 1) { throw new ArcanistUsageException( "More than one revision exists in the working copy:\n\n". $this->renderRevisionList($revisions)."\n". "Use '--revision ' to select a revision."); } } else { $revisions = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (count($revisions) == 0) { throw new ArcanistUsageException( "Revision 'D{$revision_id}' does not exist."); } } $revision = head($revisions); $this->revisionID = $revision['id']; $revision_id = $revision['id']; $this->runSanityChecks($revision); $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => false, )); $event = new PhutilEvent( ArcanistEventType::TYPE_COMMIT_WILLCOMMITSVN, array( 'message' => $message, 'workflow' => $this, ) ); PhutilEventEngine::dispatchEvent($event); $message = $event->getValue('message'); if ($this->getArgument('show')) { echo $message; return 0; } $revision_title = $revision['title']; echo "Committing 'D{$revision_id}: {$revision_title}'...\n"; $files = $this->getCommitFileList($revision); $files = implode(' ', array_map('escapeshellarg', $files)); $message = escapeshellarg($message); $root = escapeshellarg($repository_api->getPath()); $lang = $this->getSVNLangEnvVar(); // Specify LANG explicitly so that UTF-8 commit messages don't break // subversion. $command = "(cd {$root} && LANG={$lang} svn commit {$files} -m {$message})"; $err = phutil_passthru('%C', $command); if ($err) { throw new Exception("Executing 'svn commit' failed!"); } $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array( '--finalize', $revision_id, )); $mark_workflow->run(); return $err; } protected function getCommitFileList(array $revision) { $repository_api = $this->getRepositoryAPI(); $revision_id = $revision['id']; $commit_paths = $this->getConduit()->callMethodSynchronous( 'differential.getcommitpaths', array( 'revision_id' => $revision_id, )); $dir_paths = array(); foreach ($commit_paths as $path) { $path = dirname($path); while ($path != '.') { $dir_paths[$path] = true; $path = dirname($path); } } $commit_paths = array_fill_keys($commit_paths, true); $status = $repository_api->getSVNStatus(); $modified_but_not_included = array(); foreach ($status as $path => $mask) { if (!empty($dir_paths[$path])) { $commit_paths[$path] = true; } if (!empty($commit_paths[$path])) { continue; } foreach ($commit_paths as $will_commit => $ignored) { if (Filesystem::isDescendant($path, $will_commit)) { throw new ArcanistUsageException( "This commit includes the directory '{$will_commit}', but ". "it contains a modified path ('{$path}') which is NOT included ". "in the commit. Subversion can not handle this operation and ". "will commit the path anyway. You need to sort out the working ". "copy changes to '{$path}' before you may proceed with the ". "commit."); } } $modified_but_not_included[] = $path; } if ($modified_but_not_included) { if (count($modified_but_not_included) == 1) { $prefix = "A locally modified path is not included in this revision:"; $prompt = "It will NOT be committed. Commit this revision anyway?"; } else { $prefix = "Locally modified paths are not included in this revision:"; $prompt = "They will NOT be committed. Commit this revision anyway?"; } $this->promptFileWarning($prefix, $prompt, $modified_but_not_included); } $do_not_exist = array(); foreach ($commit_paths as $path => $ignored) { $disk_path = $repository_api->getPath($path); if (file_exists($disk_path)) { continue; } if (is_link($disk_path)) { continue; } if (idx($status, $path) & ArcanistRepositoryAPI::FLAG_DELETED) { continue; } $do_not_exist[] = $path; unset($commit_paths[$path]); } if ($do_not_exist) { if (count($do_not_exist) == 1) { $prefix = "Revision includes changes to a path that does not exist:"; $prompt = "Commit this revision anyway?"; } else { $prefix = "Revision includes changes to paths that do not exist:"; $prompt = "Commit this revision anyway?"; } $this->promptFileWarning($prefix, $prompt, $do_not_exist); } $files = array_keys($commit_paths); if (empty($files)) { throw new ArcanistUsageException( "There is nothing left to commit. None of the modified paths exist."); } return $files; } protected function promptFileWarning($prefix, $prompt, array $paths) { echo $prefix."\n\n"; foreach ($paths as $path) { echo " ".$path."\n"; } if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } protected function getSupportedRevisionControlSystems() { return array('svn'); } /** * On some systems, we need to specify "en_US.UTF-8" instead of "en_US.utf8", * and SVN spews some bewildering warnings if we don't: * * svn: warning: cannot set LC_CTYPE locale * svn: warning: environment variable LANG is en_US.utf8 * svn: warning: please check that your locale name is correct * * For example, is happens on my 10.6.7 machine with Subversion 1.6.15. */ private function getSVNLangEnvVar() { $locale = 'en_US.utf8'; try { list($locales) = execx('locale -a'); $locales = explode("\n", trim($locales)); $locales = array_fill_keys($locales, true); if (isset($locales['en_US.UTF-8'])) { $locale = 'en_US.UTF-8'; } } catch (Exception $ex) { // Ignore. } return $locale; } private function runSanityChecks(array $revision) { $repository_api = $this->getRepositoryAPI(); $revision_id = $revision['id']; $revision_title = $revision['title']; $confirm = array(); if ($revision['status'] != ArcanistDifferentialRevisionStatus::ACCEPTED) { $confirm[] = "Revision 'D{$revision_id}: {$revision_title}' has not been accepted. ". "Commit this revision anyway?"; } if ($revision['authorPHID'] != $this->getUserPHID()) { $confirm[] = "You are not the author of 'D{$revision_id}: {$revision_title}'. ". "Commit this revision anyway?"; } $revision_source = $revision['sourcePath']; $current_source = $repository_api->getPath(); if ($revision_source != $current_source) { $confirm[] = "Revision 'D{$revision_id}: {$revision_title}' was generated from ". "'{$revision_source}', but current working copy root is ". "'{$current_source}'. Commit this revision anyway?"; } foreach ($confirm as $thing) { if (!phutil_console_confirm($thing)) { throw new ArcanistUserAbortException(); } } } } diff --git a/src/workflow/cover/ArcanistCoverWorkflow.php b/src/workflow/cover/ArcanistCoverWorkflow.php index 44d5792f..6413a8be 100644 --- a/src/workflow/cover/ArcanistCoverWorkflow.php +++ b/src/workflow/cover/ArcanistCoverWorkflow.php @@ -1,158 +1,158 @@ getRepositoryAPI(); $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $status) { if (is_dir($path)) { unset($paths[$path]); } if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { unset($paths[$path]); } } $paths = array_keys($paths); if (!$paths) { throw new ArcanistNoEffectException( "You're covered, you didn't change anything."); } $covers = array(); foreach ($paths as $path) { $lines = $this->getChangedLines($path, 'cover'); if (!$lines) { continue; } $blame = $repository_api->getBlame($path); foreach ($lines as $line) { list($author, $revision) = idx($blame, $line, array(null, null)); if (!$author) { continue; } if (!isset($covers[$author])) { $covers[$author] = array(); } if (!isset($covers[$author][$path])) { $covers[$author][$path] = array( 'lines' => array(), 'revisions' => array(), ); } $covers[$author][$path]['lines'][] = $line; $covers[$author][$path]['revisions'][] = $revision; } } if (count($covers)) { foreach ($covers as $author => $files) { echo phutil_console_format( "**%s**\n", $author); foreach ($files as $file => $info) { $line_noun = count($info['lines']) == 1 ? 'line' : 'lines'; $lines = $this->readableSequenceFromLineNumbers($info['lines']); echo " {$file}: {$line_noun} {$lines}\n"; } } } else { echo "You're covered, your changes didn't touch anyone else's code.\n"; } return 0; } private function readableSequenceFromLineNumbers(array $array) { $sequence = array(); $last = null; $seq = null; $array = array_unique(array_map('intval', $array)); sort($array); foreach ($array as $element) { if ($seq !== null && $element == ($seq + 1)) { $seq++; continue; } if ($seq === null) { $last = $element; $seq = $element; continue; } if ($seq > $last) { $sequence[] = $last.'-'.$seq; } else { $sequence[] = $last; } $last = $element; $seq = $element; } if ($last !== null && $seq > $last) { $sequence[] = $last.'-'.$seq; } else if ($last !== null) { $sequence[] = $element; } return implode(', ', $sequence); } } diff --git a/src/workflow/diff/ArcanistDiffWorkflow.php b/src/workflow/diff/ArcanistDiffWorkflow.php index 0643fcce..49d28bcf 100644 --- a/src/workflow/diff/ArcanistDiffWorkflow.php +++ b/src/workflow/diff/ArcanistDiffWorkflow.php @@ -1,1629 +1,1629 @@ isRawDiffSource(); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { return !$this->isRawDiffSource(); } public function getDiffID() { return $this->diffID; } public function getArguments() { return array( 'message' => array( 'short' => 'm', 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Edit revisions via the web interface when using SVN.', ), 'param' => 'message', 'help' => "When updating a revision under git, use the specified message ". "instead of prompting.", ), 'message-file' => array( 'short' => 'F', 'param' => 'file', 'paramtype' => 'file', 'help' => 'When creating a revision, read revision information '. 'from this file.', ), 'use-commit-message' => array( 'supports' => array( 'git', // TODO: Support mercurial. ), 'short' => 'C', 'param' => 'commit', 'help' => 'Read revision information from a specific commit.', 'conflicts' => array( 'only' => null, 'preview' => null, 'update' => null, ), ), 'edit' => array( 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Edit revisions via the web interface when using SVN.', ), 'help' => "When updating a revision under git, edit revision information ". "before updating.", ), 'raw' => array( 'help' => "Read diff from stdin, not from the working copy. This disables ". "many Arcanist/Phabricator features which depend on having access ". "to the working copy.", 'conflicts' => array( 'less-context' => null, 'apply-patches' => '--raw disables lint.', 'never-apply-patches' => '--raw disables lint.', 'advice' => '--raw disables lint.', 'lintall' => '--raw disables lint.', 'create' => '--raw and --create both need stdin. '. 'Use --raw-command.', 'edit' => '--raw and --edit both need stdin. '. 'Use --raw-command.', 'raw-command' => null, ), ), 'raw-command' => array( 'param' => 'command', 'help' => "Generate diff by executing a specified command, not from the ". "working copy. This disables many Arcanist/Phabricator features ". "which depend on having access to the working copy.", 'conflicts' => array( 'less-context' => null, 'apply-patches' => '--raw-command disables lint.', 'never-apply-patches' => '--raw-command disables lint.', 'advice' => '--raw-command disables lint.', 'lintall' => '--raw-command disables lint.', ), ), 'create' => array( 'help' => "Always create a new revision.", 'conflicts' => array( 'edit' => '--create can not be used with --edit.', 'only' => '--create can not be used with --only.', 'preview' => '--create can not be used with --preview.', 'update' => '--create can not be used with --update.', ), ), 'update' => array( 'param' => 'revision_id', 'help' => "Always update a specific revision.", ), 'nounit' => array( 'help' => "Do not run unit tests.", ), 'nolint' => array( 'help' => "Do not run lint.", 'conflicts' => array( 'lintall' => '--nolint suppresses lint.', 'advice' => '--nolint suppresses lint.', 'apply-patches' => '--nolint suppresses lint.', 'never-apply-patches' => '--nolint suppresses lint.', ), ), 'only' => array( 'help' => "Only generate a diff, without running lint, unit tests, or other ". "auxiliary steps. See also --preview.", 'conflicts' => array( 'preview' => null, 'message' => '--only does not affect revisions.', 'edit' => '--only does not affect revisions.', 'lintall' => '--only suppresses lint.', 'advice' => '--only suppresses lint.', 'apply-patches' => '--only suppresses lint.', 'never-apply-patches' => '--only suppresses lint.', ), ), 'preview' => array( 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Revisions are never created directly when using SVN.', ), 'help' => "Instead of creating or updating a revision, only create a diff, ". "which you may later attach to a revision. This still runs lint ". "unit tests. See also --only.", 'conflicts' => array( 'only' => null, 'edit' => '--preview does affect revisions.', 'message' => '--preview does not update any revision.', ), ), 'encoding' => array( 'param' => 'encoding', 'help' => "Attempt to convert non UTF-8 hunks into specified encoding.", ), 'allow-untracked' => array( 'help' => "Skip checks for untracked files in the working copy.", ), 'less-context' => array( 'help' => "Normally, files are diffed with full context: the entire file is ". "sent to Differential so reviewers can 'show more' and see it. If ". "you are making changes to very large files with tens of thousands ". "of lines, this may not work well. With this flag, a diff will ". "be created that has only a few lines of context.", ), 'lintall' => array( 'help' => "Raise all lint warnings, not just those on lines you changed.", 'passthru' => array( 'lint' => true, ), ), 'advice' => array( 'help' => "Raise lint advice in addition to lint warnings and errors.", 'passthru' => array( 'lint' => true, ), ), 'apply-patches' => array( 'help' => 'Apply patches suggested by lint to the working copy without '. 'prompting.', 'conflicts' => array( 'never-apply-patches' => true, ), 'passthru' => array( 'lint' => true, ), ), 'never-apply-patches' => array( 'help' => 'Never apply patches suggested by lint.', 'conflicts' => array( 'apply-patches' => true, ), 'passthru' => array( 'lint' => true, ), ), 'json' => array( 'help' => 'Emit machine-readable JSON. EXPERIMENTAL! Probably does not work!', ), '*' => 'paths', ); } public function isRawDiffSource() { return $this->getArgument('raw') || $this->getArgument('raw-command'); } public function run() { if ($this->requiresRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('less-context')) { $repository_api->setDiffLinesOfContext(3); } } $output_json = $this->getArgument('json'); if ($output_json) { // TODO: We should move this to a higher-level and put an indirection // layer between echoing stuff and stdout. ob_start(); } $conduit = $this->getConduit(); if ($this->requiresWorkingCopy()) { $this->requireCleanWorkingCopy(); } $paths = $this->generateAffectedPaths(); // Do this before we start linting or running unit tests so we can detect // things like a missing test plan or invalid reviewers immediately. $commit_message = $this->buildCommitMessage(); $lint_result = $this->runLint($paths); $unit_result = $this->runUnit($paths); $changes = $this->generateChanges(); if (!$changes) { throw new ArcanistUsageException( "There are no changes to generate a diff from!"); } $diff_spec = array( 'changes' => mpull($changes, 'toDictionary'), 'lintStatus' => $this->getLintStatus($lint_result), 'unitStatus' => $this->getUnitStatus($unit_result), ) + $this->buildDiffSpecification(); $diff_info = $conduit->callMethodSynchronous( 'differential.creatediff', $diff_spec); $this->diffID = $diff_info['diffid']; if ($this->unitWorkflow) { $this->unitWorkflow->setDifferentialDiffID($diff_info['diffid']); } $this->updateLintDiffProperty(); $this->updateUnitDiffProperty(); $this->updateLocalDiffProperty(); if ($this->shouldOnlyCreateDiff()) { if (!$output_json) { echo phutil_console_format( "Created a new Differential diff:\n". " **Diff URI:** __%s__\n\n", $diff_info['uri']); } else { $human = ob_get_clean(); echo json_encode(array( 'diffURI' => $diff_info['uri'], 'diffID' => $this->getDiffID(), 'human' => $human, ))."\n"; ob_start(); } } else { $message = $commit_message; $revision = array( 'diffid' => $this->getDiffID(), 'fields' => $message->getFields(), ); if ($message->getRevisionID()) { // TODO: This is silly -- we're getting a text corpus from the server // and then sending it right back to be parsed. This should be a // single call. $remote_corpus = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $message->getRevisionID(), 'edit' => true, 'fields' => array(), )); $remote_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $remote_corpus); $remote_message->pullDataFromConduit($conduit); $sync = array('title', 'summary', 'testPlan'); foreach ($sync as $field) { $local = $message->getFieldValue($field); $remote_message->setFieldValue($field, $local); } $should_edit = $this->getArgument('edit'); /* TODO: This is a complicated mess. We need to move to storing a checksum of the non-auto-sync fields as they existed at original diff time and using changes from that to detect user edits, not comparison of the client and server values since they diverge without user edits (because of Herald and explicit server-side user changes). if (!$should_edit) { $local_sum = $message->getChecksum(); $remote_sum = $remote_message->getChecksum(); if ($local_sum != $remote_sum) { $prompt = "You have made local changes to your commit message. Arcanist ". "ignores most local changes. Instead, use the '--edit' flag to ". "edit revision information. Edit revision information now?"; $should_edit = phutil_console_confirm( $prompt, $default_no = false); } } */ $revision['fields'] = $remote_message->getFields(); if ($should_edit) { $updated_corpus = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $message->getRevisionID(), 'edit' => true, 'fields' => $message->getFields(), )); $new_text = id(new PhutilInteractiveEditor($updated_corpus)) ->setName('differential-edit-revision-info') ->editInteractively(); $new_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $new_text); $new_message->pullDataFromConduit($conduit); $revision['fields'] = $new_message->getFields(); } $update_message = $this->getUpdateMessage(); $revision['id'] = $message->getRevisionID(); $revision['message'] = $update_message; $future = $conduit->callMethod( 'differential.updaterevision', $revision); $result = $future->resolve(); echo "Updated an existing Differential revision:\n"; } else { $revision['user'] = $this->getUserPHID(); $future = $conduit->callMethod( 'differential.createrevision', $revision); $result = $future->resolve(); $revised_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $result['revisionid'], )); if ($this->requiresRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); if (($repository_api instanceof ArcanistGitAPI) && !$this->isHistoryImmutable()) { echo "Updating commit message...\n"; $repository_api->amendGitHeadCommit($revised_message); } } echo "Created a new Differential revision:\n"; } $uri = $result['uri']; echo phutil_console_format( " **Revision URI:** __%s__\n\n", $uri); } echo "Included changes:\n"; foreach ($changes as $change) { echo ' '.$change->renderTextSummary()."\n"; } if ($output_json) { ob_get_clean(); } return 0; } protected function shouldOnlyCreateDiff() { if ($this->getArgument('create')) { return false; } if ($this->getArgument('update')) { return false; } if ($this->getArgument('use-commit-message')) { return false; } if ($this->isRawDiffSource()) { return true; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { return true; } if ($repository_api instanceof ArcanistMercurialAPI) { return true; } if ($this->isHistoryImmutable()) { return true; } return $this->getArgument('preview') || $this->getArgument('only'); } private function generateAffectedPaths() { if ($this->isRawDiffSource()) { return array(); } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { $file_list = new FileList($this->getArgument('paths', array())); $paths = $repository_api->getSVNStatus($externals = true); foreach ($paths as $path => $mask) { if (!$file_list->contains($repository_api->getPath($path), true)) { unset($paths[$path]); } } $warn_externals = array(); foreach ($paths as $path => $mask) { $any_mod = ($mask & ArcanistRepositoryAPI::FLAG_ADDED) || ($mask & ArcanistRepositoryAPI::FLAG_MODIFIED) || ($mask & ArcanistRepositoryAPI::FLAG_DELETED); if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { unset($paths[$path]); if ($any_mod) { $warn_externals[] = $path; } } } if ($warn_externals && !$this->hasWarnedExternals) { echo phutil_console_format( "The working copy includes changes to 'svn:externals' paths. These ". "changes will not be included in the diff because SVN can not ". "commit 'svn:externals' changes alongside normal changes.". "\n\n". "Modified 'svn:externals' files:". "\n\n". ' '.phutil_console_wrap(implode("\n", $warn_externals), 8)); $prompt = "Generate a diff (with just local changes) anyway?"; if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } else { $this->hasWarnedExternals = true; } } } else if ($repository_api->supportsRelativeLocalCommits()) { $repository_api->parseRelativeLocalCommit( $this->getArgument('paths', array())); $paths = $repository_api->getWorkingCopyStatus(); } else { throw new Exception("Unknown VCS!"); } foreach ($paths as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } return $paths; } protected function generateChanges() { $parser = new ArcanistDiffParser(); $is_raw = $this->isRawDiffSource(); if ($is_raw) { if ($this->getArgument('raw')) { file_put_contents('php://stderr', "Reading diff from stdin...\n"); $raw_diff = file_get_contents('php://stdin'); } else if ($this->getArgument('raw-command')) { list($raw_diff) = execx($this->getArgument('raw-command')); } else { throw new Exception("Unknown raw diff source."); } $changes = $parser->parseDiff($raw_diff); foreach ($changes as $key => $change) { // Remove "message" changes, e.g. from "git show". if ($change->getType() == ArcanistDiffChangeType::TYPE_MESSAGE) { unset($changes[$key]); } } return $changes; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { $paths = $this->generateAffectedPaths(); $this->primeSubversionWorkingCopyData($paths); // Check to make sure the user is diffing from a consistent base revision. // This is mostly just an abuse sanity check because it's silly to do this // and makes the code more difficult to effectively review, but it also // affects patches and makes them nonportable. $bases = $repository_api->getSVNBaseRevisions(); // Remove all files with baserev "0"; these files are new. foreach ($bases as $path => $baserev) { if ($bases[$path] == 0) { unset($bases[$path]); } } if ($bases) { $rev = reset($bases); $revlist = array(); foreach ($bases as $path => $baserev) { $revlist[] = " Revision {$baserev}, {$path}"; } $revlist = implode("\n", $revlist); foreach ($bases as $path => $baserev) { if ($baserev !== $rev) { throw new ArcanistUsageException( "Base revisions of changed paths are mismatched. Update all ". "paths to the same base revision before creating a diff: ". "\n\n". $revlist); } } // If you have a change which affects several files, all of which are // at a consistent base revision, treat that revision as the effective // base revision. The use case here is that you made a change to some // file, which updates it to HEAD, but want to be able to change it // again without updating the entire working copy. This is a little // sketchy but it arises in Facebook Ops workflows with config files and // doesn't have any real material tradeoffs (e.g., these patches are // perfectly applyable). $repository_api->overrideSVNBaseRevisionNumber($rev); } $changes = $parser->parseSubversionDiff( $repository_api, $paths); } else if ($repository_api instanceof ArcanistGitAPI) { $diff = $repository_api->getFullGitDiff(); if (!strlen($diff)) { throw new ArcanistUsageException( "No changes found. (Did you specify the wrong commit range?)"); } $changes = $parser->parseDiff($diff); } else if ($repository_api instanceof ArcanistMercurialAPI) { $diff = $repository_api->getFullMercurialDiff(); $changes = $parser->parseDiff($diff); } else { throw new Exception("Repository API is not supported."); } if (count($changes) > 250) { $count = number_format(count($changes)); $message = "This diff has a very large number of changes ({$count}). ". "Differential works best for changes which will receive detailed ". "human review, and not as well for large automated changes or ". "bulk checkins. Continue anyway?"; if (!phutil_console_confirm($message)) { throw new ArcanistUsageException( "Aborted generation of gigantic diff."); } } $limit = 1024 * 1024 * 4; foreach ($changes as $change) { $size = 0; foreach ($change->getHunks() as $hunk) { $size += strlen($hunk->getCorpus()); } if ($size > $limit) { $file_name = $change->getCurrentPath(); $change_size = number_format($size); $byte_warning = "Diff for '{$file_name}' with context is {$change_size} bytes in ". "length. Generally, source changes should not be this large. If ". "this file is a huge text file, try using the '--less-context' flag."; if ($repository_api instanceof ArcanistSubversionAPI) { throw new ArcanistUsageException( "{$byte_warning} If the file is not a text file, mark it as ". "binary with:". "\n\n". " $ svn propset svn:mime-type application/octet-stream ". "\n"); } else { $confirm = "{$byte_warning} If the file is not a text file, you can ". "mark it 'binary'. Mark this file as 'binary' and continue?"; if (phutil_console_confirm($confirm)) { $change->convertToBinaryChange(); } else { throw new ArcanistUsageException( "Aborted generation of gigantic diff."); } } } } $try_encoding = null; $utf8_problems = array(); foreach ($changes as $change) { foreach ($change->getHunks() as $hunk) { $corpus = $hunk->getCorpus(); if (!phutil_is_utf8($corpus)) { // If this corpus is heuristically binary, don't try to convert it. // mb_check_encoding() and mb_convert_encoding() are both very very // liberal about what they're willing to process. $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); if (!$is_binary) { $try_encoding = nonempty($this->getArgument('encoding'), null); if ($try_encoding === null) { // Make a call to check if there's an encoding specified for this // project. try { $project_info = $this->getConduit()->callMethodSynchronous( 'arcanist.projectinfo', array( 'name' => $this->getWorkingCopy()->getProjectID(), )); $try_encoding = nonempty($project_info['encoding'], false); } catch (ConduitClientException $e) { if ($e->getErrorCode() == 'ERR-BAD-ARCANIST-PROJECT') { echo phutil_console_wrap( "Lookup of encoding in arcanist project failed\n". $e->getMessage() ); $try_encoding = false; } else { throw $e; } } } if ($try_encoding) { // NOTE: This feature is HIGHLY EXPERIMENTAL and will cause a lot // of issues. Use it at your own risk. $corpus = mb_convert_encoding($corpus, 'UTF-8', $try_encoding); $name = $change->getCurrentPath(); if (phutil_is_utf8($corpus)) { $this->writeStatusMessage( "[Experimental] Converted a '{$name}' hunk from ". "'{$try_encoding}' to UTF-8.\n"); $hunk->setCorpus($corpus); continue; } } } $utf8_problems[] = $change; break; } } } // If there are non-binary files which aren't valid UTF-8, warn the user // and treat them as binary changes. See D327 for discussion of why Arcanist // has this behavior. if ($utf8_problems) { $learn_more = "You can learn more about how Phabricator handles character encodings ". "(and how to configure encoding settings and detect and correct ". "encoding problems) by reading 'User Guide: UTF-8 and Character ". "Encoding' in the Phabricator documentation.\n\n"; if (count($utf8_problems) == 1) { $utf8_warning = "This diff includes a file which is not valid UTF-8 (it has invalid ". "byte sequences). You can either stop this workflow and fix it, or ". "continue. If you continue, this file will be marked as binary.\n\n". $learn_more. " AFFECTED FILE\n"; $confirm = "Do you want to mark this file as binary and continue?"; } else { $utf8_warning = "This diff includes files which are not valid UTF-8 (they contain ". "invalid byte sequences). You can either stop this workflow and fix ". "these files, or continue. If you continue, these files will be ". "marked as binary.\n\n". $learn_more. " AFFECTED FILES\n"; $confirm = "Do you want to mark these files as binary and continue?"; } echo phutil_console_format("**Invalid Content Encoding (Non-UTF8)**\n"); echo phutil_console_wrap($utf8_warning); $file_list = mpull($utf8_problems, 'getCurrentPath'); $file_list = ' '.implode("\n ", $file_list); echo $file_list; if (!phutil_console_confirm($confirm, $default_no = false)) { throw new ArcanistUsageException("Aborted workflow to fix UTF-8."); } else { foreach ($utf8_problems as $change) { $change->convertToBinaryChange(); } } } foreach ($changes as $change) { if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) { continue; } $path = $change->getCurrentPath(); $name = basename($path); $old_file = $repository_api->getOriginalFileData($path); $old_dict = $this->uploadFile($old_file, $name, 'old binary'); if ($old_dict['guid']) { $change->setMetadata('old:binary-phid', $old_dict['guid']); } $change->setMetadata('old:file:size', $old_dict['size']); $change->setMetadata('old:file:mime-type', $old_dict['mime']); $new_file = $repository_api->getCurrentFileData($path); $new_dict = $this->uploadFile($new_file, $name, 'new binary'); if ($new_dict['guid']) { $change->setMetadata('new:binary-phid', $new_dict['guid']); } $change->setMetadata('new:file:size', $new_dict['size']); $change->setMetadata('new:file:mime-type', $new_dict['mime']); if (preg_match('@^image/@', $new_dict['mime'])) { $change->setFileType(ArcanistDiffChangeType::FILE_IMAGE); } } return $changes; } private function uploadFile($data, $name, $desc) { $result = array( 'guid' => null, 'mime' => null, 'size' => null ); $result['size'] = $size = strlen($data); if (!$size) { return $result; } $future = new ExecFuture('file -b --mime -'); $future->write($data); list($mime_type) = $future->resolvex(); $mime_type = trim($mime_type); $result['mime'] = $mime_type; echo "Uploading {$desc} '{$name}' ({$mime_type}, {$size} bytes)...\n"; try { $guid = $this->getConduit()->callMethodSynchronous( 'file.upload', array( 'data_base64' => base64_encode($data), 'name' => $name, )); $result['guid'] = $guid; } catch (ConduitClientException $e) { $message = "Failed to upload {$desc} '{$name}'. Continue?"; if (!phutil_console_confirm($message, $default_no = false)) { throw new ArcanistUsageException( 'Aborted due to file upload failure.' ); } } return $result; } private function getGitParentLogInfo() { $info = array( 'parent' => null, 'base_revision' => null, 'base_path' => null, 'uuid' => null, ); $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); $history_messages = $repository_api->getGitHistoryLog(); if (!$history_messages) { // This can occur on the initial commit. return $info; } $history_messages = $parser->parseDiff($history_messages); foreach ($history_messages as $key => $change) { try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); if ($message->getRevisionID() && $info['parent'] === null) { $info['parent'] = $message->getRevisionID(); } if ($message->getGitSVNBaseRevision() && $info['base_revision'] === null) { $info['base_revision'] = $message->getGitSVNBaseRevision(); $info['base_path'] = $message->getGitSVNBasePath(); } if ($message->getGitSVNUUID()) { $info['uuid'] = $message->getGitSVNUUID(); } if ($info['parent'] && $info['base_revision']) { break; } } catch (ArcanistDifferentialCommitMessageParserException $ex) { // Ignore. } } return $info; } protected function primeSubversionWorkingCopyData($paths) { $repository_api = $this->getRepositoryAPI(); $futures = array(); $targets = array(); foreach ($paths as $path => $mask) { $futures[] = $repository_api->buildDiffFuture($path); $targets[] = array('command' => 'diff', 'path' => $path); $futures[] = $repository_api->buildInfoFuture($path); $targets[] = array('command' => 'info', 'path' => $path); } foreach ($futures as $key => $future) { $target = $targets[$key]; if ($target['command'] == 'diff') { $repository_api->primeSVNDiffResult( $target['path'], $future->resolve()); } else { $repository_api->primeSVNInfoResult( $target['path'], $future->resolve()); } } } /* -( Lint and Unit Tests )------------------------------------------------ */ /** * @task lintunit */ private function runLint($paths) { if ($this->getArgument('nolint') || $this->getArgument('only') || $this->isRawDiffSource()) { return ArcanistLintWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); echo "Linting...\n"; try { $argv = $this->getPassthruArgumentsAsArgv('lint'); if ($repository_api->supportsRelativeLocalCommits()) { $argv[] = '--rev'; $argv[] = $repository_api->getRelativeCommit(); } $lint_workflow = $this->buildChildWorkflow('lint', $argv); if (!$this->isHistoryImmutable()) { // TODO: We should offer to create a checkpoint commit. $lint_workflow->setShouldAmendChanges(true); } $lint_result = $lint_workflow->run(); switch ($lint_result) { case ArcanistLintWorkflow::RESULT_OKAY: echo phutil_console_format( "** LINT OKAY ** No lint problems.\n"); break; case ArcanistLintWorkflow::RESULT_WARNINGS: $continue = phutil_console_confirm( "Lint issued unresolved warnings. Ignore them?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; case ArcanistLintWorkflow::RESULT_ERRORS: echo phutil_console_format( "** LINT ERRORS ** Lint raised errors!\n"); $continue = phutil_console_confirm( "Lint issued unresolved errors! Ignore lint errors?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; } $this->unresolvedLint = $lint_workflow->getUnresolvedMessages(); return $lint_result; } catch (ArcanistNoEngineException $ex) { echo "No lint engine configured for this project.\n"; } catch (ArcanistNoEffectException $ex) { echo "No paths to lint.\n"; } return null; } /** * @task lintunit */ private function runUnit($paths) { if ($this->getArgument('nounit') || $this->getArgument('only') || $this->isRawDiffSource()) { return ArcanistUnitWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); echo "Running unit tests...\n"; try { $argv = $this->getPassthruArgumentsAsArgv('unit'); if ($repository_api->supportsRelativeLocalCommits()) { $argv[] = '--rev'; $argv[] = $repository_api->getRelativeCommit(); } $this->unitWorkflow = $this->buildChildWorkflow('unit', $argv); $unit_result = $this->unitWorkflow->run(); switch ($unit_result) { case ArcanistUnitWorkflow::RESULT_OKAY: echo phutil_console_format( "** UNIT OKAY ** No unit test failures.\n"); break; case ArcanistUnitWorkflow::RESULT_UNSOUND: $continue = phutil_console_confirm( "Unit test results included failures, but all failing tests ". "are known to be unsound. Ignore unsound test failures?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; case ArcanistUnitWorkflow::RESULT_FAIL: echo phutil_console_format( "** UNIT ERRORS ** Unit testing raised errors!\n"); $continue = phutil_console_confirm( "Unit test results include failures! Ignore test failures?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; } $this->unresolvedTests = $this->unitWorkflow->getUnresolvedTests(); return $unit_result; } catch (ArcanistNoEngineException $ex) { echo "No unit test engine is configured for this project.\n"; } catch (ArcanistNoEffectException $ex) { echo "No tests to run.\n"; } return null; } /* -( Commit and Update Messages )----------------------------------------- */ /** * @task message */ private function buildCommitMessage() { $is_create = $this->getArgument('create'); $is_update = $this->getArgument('update'); $is_raw = $this->isRawDiffSource(); $is_message = $this->getArgument('use-commit-message'); if ($is_message) { return $this->getCommitMessageFromCommit($is_message); } $message = null; if ($is_create) { $message_file = $this->getArgument('message-file'); if ($message_file) { return $this->getCommitMessageFromFile($message_file); } else { return $this->getCommitMessageFromUser(); } } if ($is_update) { return $this->getCommitMessageFromRevision($is_update); } if ($is_raw) { return null; } if (!$this->shouldOnlyCreateDiff()) { return $this->getGitCommitMessage(); } return null; } /** * @task message */ private function getCommitMessageFromCommit($rev) { $change = $this->getRepositoryAPI()->getCommitMessageForRevision($rev); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); $message->pullDataFromConduit($this->getConduit()); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromUser() { $conduit = $this->getConduit(); $template = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => null, 'edit' => true, )); $template = $template. "\n\n". "# Describe this revision.". "\n"; $template = id(new PhutilInteractiveEditor($template)) ->setName('new-commit') ->editInteractively(); $template = preg_replace('/^\s*#.*$/m', '', $template); try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $template); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); } catch (Exception $ex) { $path = Filesystem::writeUniqueFile('arc-commit-message', $template); echo phutil_console_wrap( "\n". "Exception while parsing commit message! Message saved to ". "'{$path}'. Use -F to specify a commit message file.\n"); throw $ex; } return $message; } /** * @task message */ private function getCommitMessageFromFile($file) { $conduit = $this->getConduit(); $data = Filesystem::readFile($file); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($data); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromRevision($revision_id) { $id = $this->normalizeRevisionID($revision_id); $revision = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($id), )); $revision = head($revision); if (!$revision) { throw new ArcanistUsageException( "Revision '{$revision_id}' does not exist!"); } if ($revision['authorPHID'] != $this->getUserPHID()) { $rev_title = $revision['title']; throw new ArcanistUsageException( "You don't own revision D{$id} '{$rev_title}'. You can only update ". "revisions you own."); } $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $id, 'edit' => false, )); $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); $obj->pullDataFromConduit($this->getConduit()); return $obj; } /** * @task message */ private function validateCommitMessage( ArcanistDifferentialCommitMessage $message) { $reviewers = $message->getFieldValue('reviewerPHIDs'); if (!$reviewers) { $confirm = "You have not specified any reviewers. Continue anyway?"; if (!phutil_console_confirm($confirm)) { throw new ArcanistUsageException('Specify reviewers and retry.'); } } else if (in_array($this->getUserPHID(), $reviewers)) { throw new ArcanistUsageException( "You can not be a reviewer for your own revision."); } } /** * @task message */ private function getUpdateMessage() { $comments = $this->getArgument('message'); if (strlen($comments)) { return $comments; } // When updating a revision using git without specifying '--message', try // to prefill with the message in HEAD if it isn't a template message. The // idea is that if you do: // // $ git commit -a -m 'fix some junk' // $ arc diff // // ...you shouldn't have to retype the update message. if ($this->requiresRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { $comments = $this->getGitUpdateMessage(); } } $template = $comments. "\n\n". "# Enter a brief description of the changes included in this update.". "\n"; $comments = id(new PhutilInteractiveEditor($template)) ->setName('differential-update-comments') ->editInteractively(); $comments = preg_replace('/^\s*#.*$/m', '', $comments); $comments = rtrim($comments); if (!strlen($comments)) { throw new ArcanistUserAbortException(); } return $comments; } /** * @task message */ private function getGitCommitMessage() { $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); $commit_messages = $repository_api->getGitCommitLog(); if (!strlen($commit_messages)) { if (!$repository_api->getHasCommits()) { throw new ArcanistUsageException( "This git repository doesn't have any commits yet. You need to ". "commit something before you can diff against it."); } else { throw new ArcanistUsageException( "The commit range doesn't include any commits. (Did you diff ". "against the wrong commit?)"); } } $commit_messages = $parser->parseDiff($commit_messages); $problems = array(); $parsed = array(); $hashes = array(); foreach ($commit_messages as $key => $change) { $problems[$key] = array(); $hashes[$key] = $change->getCommitHash(); try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); $message->pullDataFromConduit($conduit); $parsed[$key] = $message; } catch (ArcanistDifferentialCommitMessageParserException $ex) { foreach ($ex->getParserErrors() as $problem) { $problems[$key][] = $problem; } continue; } } $valid = array(); foreach ($problems as $key => $problem_list) { if ($problem_list) { continue; } $valid[$key] = $parsed[$key]; } $blessed = null; if (count($valid) == 1) { $blessed = head($valid); } else if (count($valid) > 1) { echo phutil_console_wrap( "Changes in the specified commit range include more than one commit ". "with a valid template commit message. Choose the message you want ". "to use (you can also use the -C flag).\n\n"); foreach ($valid as $key => $message) { $hash = substr($hashes[$key], 0, 7); $title = $commit_messages[$key]->getMetadata('message'); $title = head(explode("\n", trim($title))); $title = phutil_utf8_shorten($title, 64); echo " {$hash} {$title}\n"; } echo " none Edit a blank template."; do { $choose = phutil_console_prompt('Use which commit message [none]?'); if ($choose == 'none' || $choose == '') { return $this->getCommitMessageFromUser(); } else { foreach ($valid as $key => $message) { if (!strncmp($hashes[$key], $choose, strlen($choose))) { $blessed = $valid[$key]; break; } } } } while (!$blessed); } if (!$blessed) { $desc = implode("\n", array_mergev($problems)); if (count($problems) > 1) { throw new ArcanistUsageException( "All changes between the specified commits have template parsing ". "problems:\n\n".$desc."\n\nIf you only want to create a diff ". "(not a revision), use --preview to ignore commit messages."); } else if (count($problems) == 1) { $user_guide = 'http://phabricator.com/docs/phabricator/'. 'article/Arcanist_User_Guide.html'; throw new ArcanistUsageException( "Commit message is not properly formatted:\n\n".$desc."\n\n". "You should use the standard git commit template to provide a ". "commit message. If you only want to create a diff (not a ". "revision), use --preview to ignore commit messages.\n\n". "See this document for instructions on configuring the commit ". "template:\n\n {$user_guide}\n"); } } if ($blessed) { $this->validateCommitMessage($blessed); } return $blessed; } /** * Retrieve the git message in HEAD if it isn't a primary template message. * * @task message */ private function getGitUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); $commit_messages = $repository_api->getGitCommitLog(); $commit_messages = $parser->parseDiff($commit_messages); $head = reset($commit_messages); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $head->getMetadata('message')); if ($message->getRevisionID()) { return null; } return trim($message->getRawCorpus()); } /* -( Diff Specification )------------------------------------------------- */ /** * @task diffspec */ private function getLintStatus($lint_result) { $map = array( ArcanistLintWorkflow::RESULT_OKAY => 'okay', ArcanistLintWorkflow::RESULT_ERRORS => 'fail', ArcanistLintWorkflow::RESULT_WARNINGS => 'warn', ArcanistLintWorkflow::RESULT_SKIP => 'skip', ); return idx($map, $lint_result, 'none'); } /** * @task diffspec */ private function getUnitStatus($unit_result) { $map = array( ArcanistUnitWorkflow::RESULT_OKAY => 'okay', ArcanistUnitWorkflow::RESULT_FAIL => 'fail', ArcanistUnitWorkflow::RESULT_UNSOUND => 'warn', ArcanistUnitWorkflow::RESULT_SKIP => 'skip', ArcanistUnitWorkflow::RESULT_POSTPONED => 'postponed', ); return idx($map, $unit_result, 'none'); } /** * @task diffspec */ private function buildDiffSpecification() { $base_revision = null; $base_path = null; $vcs = null; $repo_uuid = null; $parent = null; $source_path = null; $branch = null; if ($this->requiresRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); $base_revision = $repository_api->getSourceControlBaseRevision(); $base_path = $repository_api->getSourceControlPath(); $vcs = $repository_api->getSourceControlSystemName(); $source_path = $repository_api->getPath(); $branch = $repository_api->getBranchName(); if ($repository_api instanceof ArcanistGitAPI) { $info = $this->getGitParentLogInfo(); if ($info['parent']) { $parent = $info['parent']; } if ($info['base_revision']) { $base_revision = $info['base_revision']; } if ($info['base_path']) { $base_path = $info['base_path']; } if ($info['uuid']) { $repo_uuid = $info['uuid']; } } else if ($repository_api instanceof ArcanistSubversionAPI) { $repo_uuid = $repository_api->getRepositorySVNUUID(); } else if ($repository_api instanceof ArcanistMercurialAPI) { // TODO: Provide this information. } else { throw new Exception("Unsupported repository API!"); } } $project_id = null; if ($this->requiresWorkingCopy()) { $project_id = $this->getWorkingCopy()->getProjectID(); } return array( 'sourceMachine' => php_uname('n'), 'sourcePath' => $source_path, 'branch' => $branch, 'sourceControlSystem' => $vcs, 'sourceControlPath' => $base_path, 'sourceControlBaseRevision' => $base_revision, 'parentRevisionID' => $parent, 'repositoryUUID' => $repo_uuid, 'creationMethod' => 'arc', 'arcanistProject' => $project_id, 'authorPHID' => $this->getUserPHID(), ); } /* -( Diff Properties )---------------------------------------------------- */ /** * Update lint information for the diff. * * @return void * * @task diffprop */ private function updateLintDiffProperty() { if (!$this->unresolvedLint) { return; } $data = array(); foreach ($this->unresolvedLint as $message) { $data[] = array( 'path' => $message->getPath(), 'line' => $message->getLine(), 'char' => $message->getChar(), 'code' => $message->getCode(), 'severity' => $message->getSeverity(), 'name' => $message->getName(), 'description' => $message->getDescription(), ); } $this->updateDiffProperty('arc:lint', json_encode($data)); } /** * Update unit test information for the diff. * * @return void * * @task diffprop */ private function updateUnitDiffProperty() { if (!$this->unresolvedTests) { return; } $data = array(); foreach ($this->unresolvedTests as $test) { $data[] = array( 'name' => $test->getName(), 'result' => $test->getResult(), 'userdata' => $test->getUserData(), ); } $this->updateDiffProperty('arc:unit', json_encode($data)); } /** * Update local commit information for the diff. * * @task diffprop */ private function updateLocalDiffProperty() { if ($this->isRawDiffSource()) { return; } $local_info = $this->getRepositoryAPI()->getLocalCommitInformation(); if (!$local_info) { return; } $this->updateDiffProperty('local:commits', json_encode($local_info)); } /** * Update an arbitrary diff property. * * @param string Diff property name. * @param string Diff property value. * @return void * * @task diffprop */ private function updateDiffProperty($name, $data) { $this->getConduit()->callMethodSynchronous( 'differential.setdiffproperty', array( 'diff_id' => $this->getDiffID(), 'name' => $name, 'data' => $data, )); } } diff --git a/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php b/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php index 1f36277e..27d13519 100644 --- a/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php +++ b/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php @@ -1,128 +1,128 @@ getWorkingCopy(); if (!$working_copy->getProjectID()) { throw new ArcanistUsageException( "You have installed a git pre-receive hook in a remote without an ". ".arcconfig."); } // Git repositories have special rules in pre-receive hooks. We need to // construct the API against the .git directory instead of the project // root or commands don't work properly. $repository_api = ArcanistGitAPI::newHookAPI($_SERVER['PWD']); $root = $working_copy->getProjectRoot(); $parser = new ArcanistDiffParser(); $mark_revisions = array(); $stdin = file_get_contents('php://stdin'); $commits = array_filter(explode("\n", $stdin)); foreach ($commits as $commit) { list($old_ref, $new_ref, $refname) = explode(' ', $commit); list($log) = execx( '(cd %s && git log -n1 %s)', $repository_api->getPath(), $new_ref); $message_log = reset($parser->parseDiff($log)); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $message_log->getMetadata('message')); $revision_id = $message->getRevisionID(); if ($revision_id) { $mark_revisions[] = $revision_id; } // TODO: Do commit message junk. $info = $repository_api->getPreReceiveHookStatus($old_ref, $new_ref); $paths = ipull($info, 'mask'); $frefs = ipull($info, 'ref'); $data = array(); foreach ($paths as $path => $mask) { list($stdout) = execx( '(cd %s && git cat-file blob %s)', $repository_api->getPath(), $frefs[$path]); $data[$path] = $stdout; } // TODO: Do commit content junk. $commit_name = $new_ref; if ($revision_id) { $commit_name = 'D'.$revision_id.' ('.$commit_name.')'; } echo "[arc pre-receive] {$commit_name} OK...\n"; } $conduit = $this->getConduit(); $futures = array(); foreach ($mark_revisions as $revision_id) { $futures[] = $conduit->callMethod( 'differential.markcommitted', array( 'revision_id' => $revision_id, )); } Futures($futures)->resolveAll(); return 0; } } diff --git a/src/workflow/help/ArcanistHelpWorkflow.php b/src/workflow/help/ArcanistHelpWorkflow.php index d4421e82..8a72513e 100644 --- a/src/workflow/help/ArcanistHelpWorkflow.php +++ b/src/workflow/help/ArcanistHelpWorkflow.php @@ -1,180 +1,180 @@ 'command', ); } public function run() { $arc_config = $this->getArcanistConfiguration(); $workflows = $arc_config->buildAllWorkflows(); ksort($workflows); $target = null; if ($this->getArgument('command')) { $target = reset($this->getArgument('command')); if (empty($workflows[$target])) { throw new ArcanistUsageException( "Unrecognized command '{$target}'. Try 'arc help'."); } } $cmdref = array(); foreach ($workflows as $command => $workflow) { if ($target && $target != $command) { continue; } $optref = array(); $arguments = $workflow->getArguments(); $config_arguments = $arc_config->getCustomArgumentsForCommand($command); // This juggling is to put the extension arguments after the normal // arguments, and make sure the normal arguments aren't overwritten. ksort($arguments); ksort($config_arguments); foreach ($config_arguments as $argument => $spec) { if (empty($arguments[$argument])) { $arguments[$argument] = $spec; } } foreach ($arguments as $argument => $spec) { if ($argument == '*') { continue; } if (!empty($spec['hide'])) { continue; } if (isset($spec['param'])) { if (isset($spec['short'])) { $optref[] = phutil_console_format( " __--%s__ __%s__, __-%s__ __%s__", $argument, $spec['param'], $spec['short'], $spec['param']); } else { $optref[] = phutil_console_format( " __--%s__ __%s__", $argument, $spec['param']); } } else { if (isset($spec['short'])) { $optref[] = phutil_console_format( " __--%s__, __-%s__", $argument, $spec['short']); } else { $optref[] = phutil_console_format( " __--%s__", $argument); } } if (isset($config_arguments[$argument])) { $optref[] = " (This is a custom option for this ". "project.)"; } if (isset($spec['supports'])) { $optref[] = " Supports: ". implode(', ', $spec['supports']); } if (isset($spec['help'])) { $docs = $spec['help']; } else { $docs = 'This option is not documented.'; } $docs = phutil_console_wrap($docs, 14); $optref[] = " {$docs}\n"; } if ($optref) { $optref = implode("\n", $optref); $optref = "\n\n".$optref; } else { $optref = "\n"; } $cmdref[] = $workflow->getCommandHelp().$optref; } $cmdref = implode("\n\n", $cmdref); if ($target) { echo "\n".$cmdref."\n"; return; } $self = 'arc'; echo phutil_console_format(<< 'uri', ); } public function shouldShellComplete() { return false; } public function requiresConduit() { return false; } public function requiresWorkingCopy() { return false; } public function run() { $uri = $this->determineConduitURI(); echo "Installing certificate for '{$uri}'...\n"; $config = self::readUserConfigurationFile(); echo "Trying to connect to server...\n"; $conduit = new ConduitClient($uri); try { $conduit->callMethodSynchronous('conduit.ping', array()); } catch (Exception $ex) { throw new ArcanistUsageException( "Failed to connect to server: ".$ex->getMessage()); } echo "Connection OK!\n"; $token_uri = new PhutilURI($uri); $token_uri->setPath('/conduit/token/'); echo "\n"; echo phutil_console_format("**LOGIN TO PHABRICATOR**\n"); echo "Open this page in your browser and login to Phabricator if ". "necessary:\n"; echo "\n"; echo " {$token_uri}\n"; echo "\n"; echo "Then paste the token on that page below."; do { $token = phutil_console_prompt('Paste token from that page:'); $token = trim($token); if (strlen($token)) { break; } } while (true); echo "\n"; echo "Downloading authentication certificate...\n"; $info = $conduit->callMethodSynchronous( 'conduit.getcertificate', array( 'token' => $token, 'host' => $uri, )); $user = $info['username']; echo "Installing certificate for '{$user}'...\n"; $config['hosts'][$uri] = array( 'user' => $user, 'cert' => $info['certificate'], ); $json_encoder = new PhutilJSON(); $json = $json_encoder->encodeFormatted($config); echo "Writing ~/.arcrc...\n"; $path = self::getUserConfigurationFileLocation(); Filesystem::writeFile($path, $json); execx('chmod 600 %s', $path); echo phutil_console_format( "** SUCCESS! ** Certificate installed.\n"); return 0; } private function determineConduitURI() { $uri = $this->getArgument('uri'); if (count($uri) > 1) { throw new ArcanistUsageException("Specify at most one URI."); } else if (count($uri) == 1) { $uri = reset($uri); } else { $conduit_uri = $this->getConduitURI(); if (!$conduit_uri) { throw new ArcanistUsageException( "Specify an explicit URI or run this command from within a project ". "which is configured with a .arcconfig."); } $uri = $conduit_uri; } $uri = new PhutilURI($uri); $uri->setPath('/api/'); return (string)$uri; } } diff --git a/src/workflow/land/ArcanistLandWorkflow.php b/src/workflow/land/ArcanistLandWorkflow.php index f6fd53cc..742896d1 100644 --- a/src/workflow/land/ArcanistLandWorkflow.php +++ b/src/workflow/land/ArcanistLandWorkflow.php @@ -1,291 +1,291 @@ array( 'param' => 'master', 'help' => "Land feature branch onto a branch other than ". "'master' (default).", ), '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 'origin' (default).", ), '*' => 'branch', ); } public function run() { $this->writeStatusMessage( phutil_console_format( "**WARNING:** 'arc land' is new and experimental.\n")); $branch = $this->getArgument('branch'); if (count($branch) !== 1) { throw new ArcanistUsageException( "Specify exactly one branch to land changes from."); } $branch = head($branch); $remote = $this->getArgument('remote', 'origin'); $onto = $this->getArgument('onto', 'master'); $is_immutable = $this->isHistoryImmutable(); $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { throw new ArcanistUsageException("'arc land' only supports git."); } list($err) = exec_manual( '(cd %s && git rev-parse --verify %s)', $repository_api->getPath(), $branch); if ($err) { throw new ArcanistUsageException("Branch '{$branch}' does not exist."); } $this->requireCleanWorkingCopy(); $repository_api->parseRelativeLocalCommit(array($remote.'/'.$onto)); execx( '(cd %s && git checkout %s)', $repository_api->getPath(), $onto); echo phutil_console_format( "Switched to branch **%s**. Updating branch...\n", $onto); execx( '(cd %s && git pull --ff-only)', $repository_api->getPath()); list($out) = execx( '(cd %s && git log %s/%s..%s)', $repository_api->getPath(), $remote, $onto, $onto); if (strlen(trim($out))) { throw new ArcanistUsageException( "Local branch '{$onto}' is ahead of '{$remote}/{$onto}', so landing ". "a feature branch would push additional changes. Push or reset the ". "changes in '{$onto}' before running 'arc land'."); } execx( '(cd %s && git checkout %s)', $repository_api->getPath(), $branch); echo phutil_console_format( "Switched to branch **%s**. Identifying and merging...\n", $branch); if (!$is_immutable) { $err = phutil_passthru( '(cd %s && git rebase %s)', $repository_api->getPath(), $onto); if ($err) { throw new ArcanistUsageException( "'git rebase {$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."); } // Now that we've rebased, the merge-base of origin/master and HEAD may // be different. Reparse the relative commit. $repository_api->parseRelativeLocalCommit(array($remote.'/'.$onto)); } $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 '{$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."); } else if (count($revisions) > 1) { $message = "There are multiple revisions on feature branch '{$branch}' which are ". "not present on '{$onto}':\n\n". $this->renderRevisionList($revisions)."\n". "Separate these revisions onto different branches, or manually land ". "them in '{$onto}'."; throw new ArcanistUsageException($message); } $revision = head($revisions); $rev_id = $revision['id']; $rev_title = $revision['title']; if ($revision['status'] != ArcanistDifferentialRevisionStatus::ACCEPTED) { $ok = phutil_console_confirm( "Revision 'D{$id}: {$rev_title}' has not been accepted. Continue ". "anyway?"); if (!$ok) { throw new ArcanistUserAbortException(); } } echo "Landing revision 'D{$rev_id}: {$rev_title}'...\n"; $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision['id'], )); execx( '(cd %s && git checkout %s)', $repository_api->getPath(), $onto); if ($is_immutable) { // In immutable histories, do a --no-ff merge to force a merge commit with // the right message. $err = phutil_passthru( '(cd %s && git merge --no-ff -m %s %s)', $repository_api->getPath(), $message, $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, and then push."); } } else { // In mutable histories, do a --squash merge. execx( '(cd %s && git merge --squash --ff-only %s)', $repository_api->getPath(), $branch); execx( '(cd %s && git commit -m %s)', $repository_api->getPath(), $message); } if ($this->getArgument('hold')) { echo phutil_console_format( "Holding change in **%s**: it has NOT been pushed yet.\n", $onto); } else { echo "Pushing change...\n\n"; $err = phutil_passthru( '(cd %s && git push %s %s)', $repository_api->getPath(), $remote, $onto); if ($err) { throw new ArcanistUsageException("'git push' failed."); } echo "\n"; } if (!$this->getArgument('keep-branch')) { list($ref) = execx( '(cd %s && git rev-parse --verify %s)', $repository_api->getPath(), $branch); $ref = trim($ref); $recovery_command = csprintf( 'git checkout -b %s %s', $branch, $ref); echo "Cleaning up feature branch...\n"; echo "(Use `{$recovery_command}` if you want it back.)\n"; execx( '(cd %s && git branch -D %s)', $repository_api->getPath(), $branch); } echo "Done.\n"; return 0; } protected function getSupportedRevisionControlSystems() { return array('git'); } } diff --git a/src/workflow/liberate/ArcanistLiberateWorkflow.php b/src/workflow/liberate/ArcanistLiberateWorkflow.php index 68289392..f779d348 100644 --- a/src/workflow/liberate/ArcanistLiberateWorkflow.php +++ b/src/workflow/liberate/ArcanistLiberateWorkflow.php @@ -1,336 +1,336 @@ array( 'help' => "Drop the module cache before liberating. This will completely ". "reanalyze the entire library. Thorough, but slow!", ), 'force-update' => array( 'help' => "Force the library map to be updated, even in the presence of ". "lint errors.", ), 'remap' => array( 'hide' => true, 'help' => "Internal. Run the remap step of liberation. You do not need to ". "run this unless you are debugging the workflow.", ), 'verify' => array( 'hide' => true, 'help' => "Internal. Run the verify step of liberation. You do not need to ". "run this unless you are debugging the workflow.", ), '*' => 'argv', ); } public function run() { $argv = $this->getArgument('argv'); if (count($argv) > 1) { throw new ArcanistUsageException( "Provide only one path to 'arc liberate'. The path should be a ". "directory where you want to create or update a libphutil library."); } else if (count($argv) == 0) { $path = getcwd(); } else { $path = reset($argv); } $is_remap = $this->getArgument('remap'); $is_verify = $this->getArgument('verify'); $path = Filesystem::resolvePath($path); if (Filesystem::pathExists($path) && is_dir($path)) { $init = id(new FileFinder($path)) ->withPath('*/__phutil_library_init__.php') ->find(); } else { $init = null; } if ($init) { if (count($init) > 1) { throw new ArcanistUsageException( "Specified directory contains more than one libphutil library. Use ". "a more specific path."); } $path = Filesystem::resolvePath(dirname(reset($init)), $path); } else { $found = false; foreach (Filesystem::walkToRoot($path) as $dir) { if (Filesystem::pathExists($dir.'/__phutil_library_init__.php')) { $path = $dir; break; } } if (!$found) { echo "No library currently exists at that path...\n"; $this->liberateCreateDirectory($path); $this->liberateCreateLibrary($path); } } if ($this->getArgument('remap')) { return $this->liberateRunRemap($path); } if ($this->getArgument('verify')) { return $this->liberateRunVerify($path); } $readable = Filesystem::readablePath($path); echo "Using library root at '{$readable}'...\n"; if ($this->getArgument('all')) { echo "Dropping module cache...\n"; Filesystem::remove($path.'/.phutil_module_cache'); } echo "Mapping library...\n"; // Force a rebuild of the library map before running lint. The remap // operation will load the map before regenerating it, so if a class has // been renamed (say, from OldClass to NewClass) this rebuild will // cause the initial remap to see NewClass and correctly remove includes // caused by use of OldClass. $this->liberateGetChangedPaths($path); $arc_bin = $this->getScriptPath('bin/arc'); do { $future = new ExecFuture( '%s liberate --remap -- %s', $arc_bin, $path); $wrote = $future->resolveJSON(); foreach ($wrote as $wrote_path) { echo "Updated '{$wrote_path}'...\n"; } } while ($wrote); echo "Verifying library...\n"; $err = 0; $cmd = csprintf('%s liberate --verify -- %s', $arc_bin, $path); passthru($cmd, $err); $do_update = (!$err || $this->getArgument('force-update')); if ($do_update) { echo "Finalizing library map...\n"; execx('%s %s', $this->getPhutilMapperLocation(), $path); } if ($err) { if ($do_update) { echo phutil_console_format( "** WARNING ** Library update forced, but lint ". "failures remain.\n"); } else { echo phutil_console_format( "** UNRESOLVED LINT ERRORS ** This library has ". "unresolved lint failures. The library map was not updated. Use ". "--force-update to force an update.\n"); } } else { echo phutil_console_format( "** OKAY ** Library updated.\n"); } return $err; } private function liberateLintModules($path, array $changed) { $engine = $this->liberateBuildLintEngine($path, $changed); if ($engine) { return $engine->run(); } else { return array(); } } private function liberateWritePatches(array $results) { $wrote = array(); foreach ($results as $result) { if ($result->isPatchable()) { $patcher = ArcanistLintPatcher::newFromArcanistLintResult($result); $patcher->writePatchToDisk(); $wrote[] = $result->getPath(); } } return $wrote; } private function liberateBuildLintEngine($path, array $changed) { $lint_map = array(); foreach ($changed as $module) { $module_path = $path.'/'.$module; $files = Filesystem::listDirectory($module_path); $lint_map[$module] = $files; } $working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile( $path, json_encode( array( 'project_id' => '__arcliberate__', )), 'arc liberate'); $engine = new ArcanistLiberateLintEngine(); $engine->setWorkingCopy($working_copy); $lint_paths = array(); foreach ($lint_map as $module => $files) { foreach ($files as $file) { $lint_paths[] = $module.'/'.$file; } } if (!$lint_paths) { return null; } $engine->setPaths($lint_paths); $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ERROR); return $engine; } private function liberateCreateDirectory($path) { if (Filesystem::pathExists($path)) { if (!is_dir($path)) { throw new ArcanistUsageException( "Provide a directory to create or update a libphutil library in."); } return; } echo "The directory '{$path}' does not exist."; if (!phutil_console_confirm('Do you want to create it?')) { throw new ArcanistUsageException("Cancelled."); } execx('mkdir -p %s', $path); } private function liberateCreateLibrary($path) { $init_path = $path.'/__phutil_library_init__.php'; if (Filesystem::pathExists($init_path)) { return; } echo "Creating new libphutil library in '{$path}'.\n"; echo "Choose a name for the new library.\n"; do { $name = phutil_console_prompt('What do you want to name this library?'); if (preg_match('/^[a-z]+$/', $name)) { break; } else { echo "Library name should contain only lowercase letters.\n"; } } while (true); $template = "getPhutilMapperLocation(); $future = new ExecFuture('%s %s --find-paths-for-liberate', $mapper, $path); return $future->resolveJSON(); } private function getScriptPath($script) { $root = dirname(phutil_get_library_root('arcanist')); return $root.'/'.$script; } private function getPhutilMapperLocation() { return $this->getScriptPath('scripts/phutil_mapper.php'); } private function liberateRunRemap($path) { phutil_load_library($path); $paths = $this->liberateGetChangedPaths($path); $results = $this->liberateLintModules($path, $paths); $wrote = $this->liberateWritePatches($results); echo json_encode($wrote, true); return 0; } private function liberateRunVerify($path) { phutil_load_library($path); $paths = $this->liberateGetChangedPaths($path); $results = $this->liberateLintModules($path, $paths); $renderer = new ArcanistLintRenderer(); $unresolved = false; foreach ($results as $result) { foreach ($result->getMessages() as $message) { echo $renderer->renderLintResult($result); $unresolved = true; break; } } return (int)$unresolved; } } diff --git a/src/workflow/list/ArcanistListWorkflow.php b/src/workflow/list/ArcanistListWorkflow.php index 0ab5fd65..f020ebf1 100644 --- a/src/workflow/list/ArcanistListWorkflow.php +++ b/src/workflow/list/ArcanistListWorkflow.php @@ -1,86 +1,86 @@ getConduit(); $repository_api = $this->getRepositoryAPI(); $revision_future = $conduit->callMethod( 'differential.find', array( 'guids' => array($this->getUserPHID()), 'query' => 'open', )); $revisions = array(); foreach ($revision_future->resolve() as $revision_dict) { $revisions[] = ArcanistDifferentialRevisionRef::newFromDictionary( $revision_dict); } if (!$revisions) { echo "You have no open Differential revisions.\n"; return 0; } foreach ($revisions as $revision) { $revision_path = Filesystem::resolvePath($revision->getSourcePath()); $current_path = Filesystem::resolvePath($repository_api->getPath()); $from_here = ($revision_path == $current_path); printf( " %15s | %s | D%d | %s\n", $revision->getStatusName(), $from_here ? '*' : ' ', $revision->getID(), $revision->getName()); } return 0; } } diff --git a/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php b/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php index a83b5f25..6d4a326d 100644 --- a/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php +++ b/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php @@ -1,161 +1,161 @@ array( 'help' => "Mark committed only if the repository is untracked and the ". "revision is accepted. Continue even if the mark can't happen. This ". "is a soft version of 'mark-committed' used by other workflows.", ), '*' => 'revision', ); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { // NOTE: Technically we only use this to generate the right message at // the end, and you can even get the wrong message (e.g., if you run // "arc mark-committed D123" from a git repository, but D123 is an SVN // revision). We could be smarter about this, but it's just display fluff. return true; } public function run() { $is_finalize = $this->getArgument('finalize'); $conduit = $this->getConduit(); $revision_list = $this->getArgument('revision', array()); if (!$revision_list) { throw new ArcanistUsageException( "mark-committed requires a revision number."); } if (count($revision_list) != 1) { throw new ArcanistUsageException( "mark-committed requires exactly one revision."); } $revision_id = reset($revision_list); $revision_id = $this->normalizeRevisionID($revision_id); $revision = null; try { $revision = $conduit->callMethodSynchronous( 'differential.getrevision', array( 'revision_id' => $revision_id, ) ); } catch (Exception $ex) { if (!$is_finalize) { throw new ArcanistUsageException( "Revision D{$revision_id} does not exist." ); } } if (!$is_finalize && $revision['statusName'] != 'Accepted') { throw new ArcanistUsageException( "Revision D{$revision_id} is not committable. You can only mark ". "revisions which have been 'accepted' as committed." ); } if ($revision) { if (!$is_finalize && $revision['authorPHID'] != $this->getUserPHID()) { $prompt = "You are not the author of revision D{$revision_id}, ". 'are you sure you want to mark it committed?'; if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } $actually_mark = true; if ($is_finalize) { $project_info = $conduit->callMethodSynchronous( 'arcanist.projectinfo', array( 'name' => $this->getWorkingCopy()->getProjectID(), )); if ($project_info['tracked'] || $revision['statusName'] != 'Accepted') { $actually_mark = false; } } if ($actually_mark) { $revision_name = $revision['title']; echo "Marking revision D{$revision_id} '{$revision_name}' ". "committed...\n"; $conduit->callMethodSynchronous( 'differential.markcommitted', array( 'revision_id' => $revision_id, )); } } $status = $revision['statusName']; if ($status == 'Accepted' || $status == 'Committed') { // If this has already been attached to commits, don't show the // "you can push this commit" message since we know it's been committed // already. $is_finalized = empty($revision['commits']); } else { $is_finalized = false; } if ($is_finalized) { $message = $this->getRepositoryAPI()->getFinalizedRevisionMessage(); echo phutil_console_wrap($message)."\n"; } else { echo "Done.\n"; } return 0; } } diff --git a/src/workflow/merge/ArcanistMergeWorkflow.php b/src/workflow/merge/ArcanistMergeWorkflow.php index 881e1fb9..0271c127 100644 --- a/src/workflow/merge/ArcanistMergeWorkflow.php +++ b/src/workflow/merge/ArcanistMergeWorkflow.php @@ -1,163 +1,163 @@ " or "hg merge --rev " of a reviewed branch, but give the merge commit a useful commit message with information from Differential. In Git, this operates like "git merge " and should be executed from the branch you want to merge __into__, just like "git merge". Branch is required. In Mercurial, this operates like "hg merge" (default) or "hg merge --rev " and should be executed from the branch you want to merge __from__, just like "hg merge". It will also effect an "hg commit" with a rich commit message. EOTEXT ); } public function requiresWorkingCopy() { return true; } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { return true; } public function getArguments() { return array( 'show' => array( 'help' => "Don't merge, just show the commit message." ), 'revision' => array( 'param' => 'revision', 'help' => "Use the message for a specific revision. If 'arc' can't figure ". "out which revision you want, you can tell it explicitly.", ), '*' => 'branch', ); } public function run() { $this->writeStatusMessage( phutil_console_format( "**WARNING:** 'arc merge' is new and experimental.\n")); $repository_api = $this->getRepositoryAPI(); if (!$repository_api->supportsLocalBranchMerge()) { $name = $repository_api->getSourceControlSystemName(); throw new ArcanistUsageException( "This source control system ('{$name}') does not support 'arc merge'."); } if ($repository_api->getUncommittedChanges()) { throw new ArcanistUsageException( "You have uncommitted changes in this working copy. Commit ". "(or revert) them before proceeding."); } $branch = $this->getArgument('branch'); if (count($branch) > 1) { throw new ArcanistUsageException("Specify only one branch to merge."); } else { $branch = head($branch); } $conduit = $this->getConduit(); $revisions = $conduit->callMethodSynchronous( 'differential.find', array( 'guids' => array($this->getUserPHID()), 'query' => 'committable', )); // TODO: Make an effort to guess which revision the user means here. Branch // name is a very strong heuristic but Conduit doesn't make it easy to get // right now. We now also have "commits:local" after D857. Between these // we should be able to get this right automatically in essentially every // reasonable case. try { $revision = $this->chooseRevision( $revisions, $this->getArgument('revision'), 'Which revision do you want to merge?'); $revision_id = $revision->getID(); } catch (ArcanistChooseInvalidRevisionException $ex) { throw new ArcanistUsageException( "You can only merge Differential revisions which have been accepted."); } catch (ArcanistChooseNoRevisionsException $ex) { throw new ArcanistUsageException( "You have no accepted Differential revisions."); } $message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => false, )); if ($this->getArgument('show')) { echo $message."\n"; } else { $repository_api->performLocalBranchMerge($branch, $message); echo "Merged '{$branch}'.\n"; $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array( '--finalize', $revision_id, )); $mark_workflow->run(); } return 0; } protected function getSupportedRevisionControlSystems() { return array('git', 'hg'); } } diff --git a/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php b/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php index 7cd8463e..c297ac0b 100644 --- a/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php +++ b/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php @@ -1,164 +1,164 @@ array( 'help' => 'Current term in the argument list being completed.', 'param' => 'cursor_position', ), '*' => 'argv', ); } public function shouldShellComplete() { return false; } public function run() { $pos = $this->getArgument('current'); $argv = $this->getArgument('argv', array()); $argc = count($argv); if ($pos === null) { $pos = $argc - 1; } // Determine which revision control system the working copy uses, so we // can filter out commands and flags which aren't supported. If we can't // figure it out, just return all flags/commands. $vcs = null; // We have to build our own because if we requiresWorkingCopy() we'll throw // if we aren't in a .arcconfig directory. We probably still can't do much, // but commands can raise more detailed errors. $working_copy = ArcanistWorkingCopyIdentity::newFromPath(getcwd()); if ($working_copy->getProjectRoot()) { $repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( $working_copy); $vcs = $repository_api->getSourceControlSystemName(); } $arc_config = $this->getArcanistConfiguration(); if ($pos == 1) { $workflows = $arc_config->buildAllWorkflows(); $complete = array(); foreach ($workflows as $name => $workflow) { if (!$workflow->shouldShellComplete()) { continue; } $supported = $workflow->getSupportedRevisionControlSystems(); $ok = (in_array('any', $supported)) || (in_array($vcs, $supported)); if (!$ok) { continue; } $complete[] = $name; } echo implode(' ', $complete)."\n"; return 0; } else { $workflow = $arc_config->buildWorkflow($argv[1]); if (!$workflow) { return 1; } $arguments = $workflow->getArguments(); $prev = idx($argv, $pos - 1, null); if (!strncmp($prev, '--', 2)) { $prev = substr($prev, 2); } else { $prev = null; } if ($prev !== null && isset($arguments[$prev]) && isset($arguments[$prev]['param'])) { $type = idx($arguments[$prev], 'paramtype'); switch ($type) { case 'file': echo "FILE\n"; break; case 'complete': echo implode(' ', $workflow->getShellCompletions($argv))."\n"; break; default: echo "ARGUMENT\n"; break; } return 0; } else { $output = array(); foreach ($arguments as $argument => $spec) { if ($argument == '*') { continue; } if ($vcs && isset($spec['supports']) && !in_array($vcs, $spec['supports'])) { continue; } $output[] = '--'.$argument; } $cur = idx($argv, $pos, ''); $any_match = false; foreach ($output as $possible) { if (!strncmp($possible, $cur, strlen($cur))) { $any_match = true; } } if (!$any_match && isset($arguments['*'])) { // TODO: the '*' specifier should probably have more details about // whether or not it is a list of files. Since it almost always is in // practice, assume FILE for now. echo "FILE\n"; } else { echo implode(' ', $output)."\n"; } return 0; } } } } diff --git a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php index 4e2acd3d..a8fc9dde 100644 --- a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php +++ b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php @@ -1,238 +1,238 @@ 'svnargs', ); } public function shouldShellComplete() { return false; } public function run() { $svnargs = $this->getArgument('svnargs'); $repository = $svnargs[0]; $transaction = $svnargs[1]; list($commit_message) = execx( 'svnlook log --transaction %s %s', $transaction, $repository); if (strpos($commit_message, '@bypass-lint') !== false) { return 0; } // TODO: Do stuff with commit message. list($changed) = execx( 'svnlook changed --transaction %s %s', $transaction, $repository); $paths = array(); $changed = explode("\n", trim($changed)); foreach ($changed as $line) { $matches = null; preg_match('/^..\s*(.*)$/', $line, $matches); $paths[$matches[1]] = strlen($matches[1]); } $resolved = array(); $failed = array(); $missing = array(); $found = array(); asort($paths); foreach ($paths as $path => $length) { foreach ($resolved as $rpath => $root) { if (!strncmp($path, $rpath, strlen($rpath))) { $resolved[$path] = $root; continue 2; } } $config = $path; if (basename($config) == '.arcconfig') { $resolved[$config] = $config; continue; } $config = rtrim($config, '/'); $last_config = $config; do { if (!empty($missing[$config])) { break; } else if (!empty($found[$config])) { $resolved[$path] = $found[$config]; break; } list($err) = exec_manual( 'svnlook cat --transaction %s %s %s', $transaction, $repository, $config ? $config.'/.arcconfig' : '.arcconfig'); if ($err) { $missing[$path] = true; } else { $resolved[$path] = $config ? $config.'/.arcconfig' : '.arcconfig'; $found[$config] = $resolved[$path]; break; } $config = dirname($config); if ($config == '.') { $config = ''; } if ($config == $last_config) { break; } $last_config = $config; } while (true); if (empty($resolved[$path])) { $failed[] = $path; } } if ($failed && $resolved) { $failed_paths = ' '.implode("\n ", $failed); $resolved_paths = ' '.implode("\n ", array_keys($resolved)); throw new ArcanistUsageException( "This commit includes a mixture of files in Arcanist projects and ". "outside of Arcanist projects. A commit which affects an Arcanist ". "project must affect only that project.\n\n". "Files in projects:\n\n". $resolved_paths."\n\n". "Files not in projects:\n\n". $failed_paths); } if (!$resolved) { // None of the affected paths are beneath a .arcconfig file. return 0; } $groups = array(); foreach ($resolved as $path => $project) { $groups[$project][] = $path; } if (count($groups) > 1) { $message = array(); foreach ($groups as $config => $group) { $message[] = "Files underneath '{$config}':\n\n"; $message[] = " ".implode("\n ", $group)."\n\n"; } $message = implode('', $message); throw new ArcanistUsageException( "This commit includes a mixture of files from different Arcanist ". "projects. A commit which affects an Arcanist project must affect ". "only that project.\n\n". $message); } $config_file = key($groups); $project_root = dirname($config_file); $paths = reset($groups); list($config) = execx( 'svnlook cat --transaction %s %s %s', $transaction, $repository, $config_file); $working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile( $project_root, $config, $config_file." (svnlook: {$transaction} {$repository})"); $repository_api = new ArcanistSubversionHookAPI( $project_root, $transaction, $repository); $lint_engine = $working_copy->getConfig('lint_engine'); if (!$lint_engine) { return 0; } PhutilSymbolLoader::loadClass($lint_engine); $engine = newv($lint_engine, array()); $engine->setWorkingCopy($working_copy); $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ERROR); $engine->setPaths($paths); $engine->setCommitHookMode(true); $engine->setHookAPI($repository_api); try { $results = $engine->run(); } catch (ArcanistNoEffectException $no_effect) { // Nothing to do, bail out. return 0; } $renderer = new ArcanistLintRenderer(); $failures = array(); foreach ($results as $result) { if (!$result->getMessages()) { continue; } $failures[] = $result; } if ($failures) { $at = "@"; $msg = phutil_console_format( "\n**LINT ERRORS**\n\n". "This changeset has lint errors. You must fix all lint errors before ". "you can commit.\n\n". "You can add '{$at}bypass-lint' to your commit message to disable ". "lint checks for this commit, or '{$at}nolint' to the file with ". "errors to disable lint for that file.\n\n"); echo phutil_console_wrap($msg); foreach ($failures as $result) { echo $renderer->renderLintResult($result); } return 1; } return 0; } } diff --git a/src/workflow/unit/ArcanistUnitWorkflow.php b/src/workflow/unit/ArcanistUnitWorkflow.php index 9b9c21a2..4c4c1259 100644 --- a/src/workflow/unit/ArcanistUnitWorkflow.php +++ b/src/workflow/unit/ArcanistUnitWorkflow.php @@ -1,232 +1,232 @@ array( 'param' => 'revision', 'help' => "Run unit tests covering changes since a specific revision.", 'supports' => array( 'git', 'hg', ), 'nosupport' => array( 'svn' => "Arc unit does not currently support --rev in SVN.", ), ), 'engine' => array( 'param' => 'classname', 'help' => "Override configured unit engine for this project." ), '*' => 'paths', ); } public function requiresWorkingCopy() { return true; } public function requiresRepositoryAPI() { return true; } public function getEngine() { return $this->engine; } public function run() { $working_copy = $this->getWorkingCopy(); $engine_class = $this->getArgument( 'engine', $working_copy->getConfig('unit_engine')); if (!$engine_class) { throw new ArcanistNoEngineException( "No unit test engine is configured for this project. Edit .arcconfig ". "to specify a unit test engine."); } $paths = $this->getArgument('paths'); $rev = $this->getArgument('rev'); $paths = $this->selectPathsForWorkflow($paths, $rev); PhutilSymbolLoader::loadClass($engine_class); if (!is_subclass_of($engine_class, 'ArcanistBaseUnitTestEngine')) { throw new ArcanistUsageException( "Configured unit test engine '{$engine_class}' is not a subclass of ". "'ArcanistBaseUnitTestEngine'."); } $this->engine = newv($engine_class, array()); $this->engine->setWorkingCopy($working_copy); $this->engine->setPaths($paths); $this->engine->setArguments($this->getPassthruArgumentsAsMap('unit')); // Enable possible async tests only for 'arc diff' not 'arc unit' if ($this->getParentWorkflow()) { $this->engine->setEnableAsyncTests(true); } else { $this->engine->setEnableAsyncTests(false); } $results = $this->engine->run(); $status_codes = array( ArcanistUnitTestResult::RESULT_PASS => phutil_console_format( '** PASS **'), ArcanistUnitTestResult::RESULT_FAIL => phutil_console_format( '** FAIL **'), ArcanistUnitTestResult::RESULT_SKIP => phutil_console_format( '** SKIP **'), ArcanistUnitTestResult::RESULT_BROKEN => phutil_console_format( '** BROKEN **'), ArcanistUnitTestResult::RESULT_UNSOUND => phutil_console_format( '** UNSOUND **'), ArcanistUnitTestResult::RESULT_POSTPONED => phutil_console_format( '** POSTPONED **'), ); $unresolved = array(); $postponed_count = 0; foreach ($results as $result) { $result_code = $result->getResult(); if ($result_code == ArcanistUnitTestResult::RESULT_POSTPONED) { $postponed_count++; $unresolved[] = $result; } else { if ($this->engine->shouldEchoTestResults()) { echo ' '.$status_codes[$result_code]; if ($result_code == ArcanistUnitTestResult::RESULT_PASS) { echo ' '.self::formatTestDuration($result->getDuration()); } echo ' '.$result->getName()."\n"; } if ($result_code != ArcanistUnitTestResult::RESULT_PASS) { if ($this->engine->shouldEchoTestResults()) { echo $result->getUserData()."\n"; } $unresolved[] = $result; } } } if ($postponed_count) { echo sprintf("%s %d %s\n", $status_codes[ArcanistUnitTestResult::RESULT_POSTPONED], $postponed_count, ($postponed_count > 1)?'tests':'test'); } $this->unresolvedTests = $unresolved; $overall_result = self::RESULT_OKAY; foreach ($results as $result) { $result_code = $result->getResult(); if ($result_code == ArcanistUnitTestResult::RESULT_FAIL || $result_code == ArcanistUnitTestResult::RESULT_BROKEN) { $overall_result = self::RESULT_FAIL; break; } else if ($result_code == ArcanistUnitTestResult::RESULT_UNSOUND) { $overall_result = self::RESULT_UNSOUND; } else if ($result_code == ArcanistUnitTestResult::RESULT_POSTPONED && $overall_result != self::RESULT_UNSOUND) { $overall_result = self::RESULT_POSTPONED; } } return $overall_result; } public function getUnresolvedTests() { return $this->unresolvedTests; } public function setDifferentialDiffID($id) { if ($this->engine) { $this->engine->setDifferentialDiffID($id); } } private static function formatTestDuration($seconds) { // Very carefully define inclusive upper bounds on acceptable unit test // durations. Times are in milliseconds and are in increasing order. $acceptableness = array( 50 => "%s\xE2\x98\x85 ", 200 => '%s ', 500 => '%s ', INF => '%s ', ); $milliseconds = $seconds * 1000; $duration = self::formatTime($seconds); foreach ($acceptableness as $upper_bound => $formatting) { if ($milliseconds <= $upper_bound) { return phutil_console_format($formatting, $duration); } } return phutil_console_format(end($acceptableness), $duration); } private static function formatTime($seconds) { if ($seconds >= 60) { $minutes = floor($seconds / 60); return sprintf('%dm%02ds', $minutes, round($seconds % 60)); } if ($seconds >= 1) { return sprintf('%4.1fs', $seconds); } $milliseconds = $seconds * 1000; if ($milliseconds >= 1) { return sprintf('%3dms', round($milliseconds)); } return ' <1ms'; } } diff --git a/src/workflow/which/ArcanistWhichWorkflow.php b/src/workflow/which/ArcanistWhichWorkflow.php index 0c73bb7e..d344b0e1 100644 --- a/src/workflow/which/ArcanistWhichWorkflow.php +++ b/src/workflow/which/ArcanistWhichWorkflow.php @@ -1,116 +1,116 @@ array( 'help' => "Show revisions by any author, not just you.", ), 'any-status' => array( 'help' => "Show committed and abandoned revisions.", ), 'id' => array( 'help' => "If exactly one revision matches, print it to stdout. ". "Otherwise, exit with an error. Intended for scripts.", ), '*' => 'commit', ); } public function run() { $repository_api = $this->getRepositoryAPI(); $commit = $this->getArgument('commit'); if (count($commit)) { if (!$repository_api->supportsRelativeLocalCommits()) { throw new ArcanistUsageException( "This version control system does not support relative commits."); } else { $repository_api->parseRelativeLocalCommit($commit); } } $any_author = $this->getArgument('any-author'); $any_status = $this->getArgument('any-status'); $query = array( 'authors' => $any_author ? null : array($this->getUserPHID()), 'status' => $any_status ? 'status-any' : 'status-open', ); $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), $query); if (empty($revisions)) { $this->writeStatusMessage("No matching revisions.\n"); return 1; } if ($this->getArgument('id')) { if (count($revisions) == 1) { echo idx(head($revisions), 'id'); return 0; } else { $this->writeStatusMessage("More than one matching revision.\n"); return 1; } } foreach ($revisions as $revision) { echo 'D'.$revision['id'].' '.$revision['title']."\n"; } return 0; } } diff --git a/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php b/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php index 7763ac9a..0468074d 100644 --- a/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php +++ b/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php @@ -1,101 +1,101 @@ projectRoot = $root; $this->projectConfig = $config; } public function getProjectID() { return $this->getConfig('project_id'); } public function getProjectRoot() { return $this->projectRoot; } public function getConduitURI() { return $this->getConfig('conduit_uri'); } public function getConfig($key) { if (!empty($this->projectConfig[$key])) { return $this->projectConfig[$key]; } return null; } }