Page MenuHomePhabricator

No OneTemporary

diff --git a/src/lint/linter/ArcanistPyLintLinter.php b/src/lint/linter/ArcanistPyLintLinter.php
index 4884ab9f..44793a50 100644
--- a/src/lint/linter/ArcanistPyLintLinter.php
+++ b/src/lint/linter/ArcanistPyLintLinter.php
@@ -1,178 +1,183 @@
<?php
/**
* Uses "PyLint" to detect various errors in Python code.
*/
final class ArcanistPyLintLinter extends ArcanistExternalLinter {
private $config;
public function getInfoName() {
return 'PyLint';
}
public function getInfoURI() {
return 'http://www.pylint.org/';
}
public function getInfoDescription() {
return pht(
'PyLint is a Python source code analyzer which looks for '.
'programming errors, helps enforcing a coding standard and '.
'sniffs for some code smells.');
}
public function getLinterName() {
return 'PyLint';
}
public function getLinterConfigurationName() {
return 'pylint';
}
public function getDefaultBinary() {
return 'pylint';
}
public function getVersion() {
list($stdout) = execx('%C --version', $this->getExecutableCommand());
$matches = array();
$regex = '/^pylint (?P<version>\d+\.\d+\.\d+),/';
if (preg_match($regex, $stdout, $matches)) {
return $matches['version'];
} else {
return false;
}
}
public function getInstallInstructions() {
return pht(
'Install PyLint using `%s`.',
'pip install pylint');
}
public function shouldExpectCommandErrors() {
return true;
}
public function getLinterConfigurationOptions() {
$options = array(
'pylint.config' => array(
'type' => 'optional string',
'help' => pht('Pass in a custom configuration file path.'),
),
);
return $options + parent::getLinterConfigurationOptions();
}
public function setLinterConfigurationValue($key, $value) {
switch ($key) {
case 'pylint.config':
$this->config = $value;
return;
default:
return parent::setLinterConfigurationValue($key, $value);
}
}
protected function getMandatoryFlags() {
$options = array();
$options[] = '--reports=no';
$options[] = '--msg-template={line}|{column}|{msg_id}|{symbol}|{msg}';
// Specify an `--rcfile`, either absolute or relative to the project root.
// Stupidly, the command line args above are overridden by rcfile, so be
// careful.
$config = $this->config;
if ($config !== null) {
$options[] = '--rcfile='.$config;
}
return $options;
}
protected function getDefaultFlags() {
$options = array();
$installed_version = $this->getVersion();
$minimum_version = '1.0.0';
if (version_compare($installed_version, $minimum_version, '<')) {
throw new ArcanistMissingLinterException(
pht(
'%s is not compatible with the installed version of pylint. '.
'Minimum version: %s; installed version: %s.',
__CLASS__,
$minimum_version,
$installed_version));
}
return $options;
}
protected function parseLinterOutput($path, $err, $stdout, $stderr) {
if ($err === 32) {
// According to `man pylint` the exit status of 32 means there was a
// usage error. That's bad, so actually exit abnormally.
return false;
}
$lines = phutil_split_lines($stdout, false);
$messages = array();
foreach ($lines as $line) {
$matches = explode('|', $line, 5);
if (count($matches) < 5) {
continue;
}
+ // NOTE: PyLint sometimes returns -1 as the character offset for a
+ // message. If it does, treat it as 0. See T9257.
+ $char = (int)$matches[1];
+ $char = max(0, $char);
+
$message = id(new ArcanistLintMessage())
->setPath($path)
->setLine($matches[0])
- ->setChar($matches[1])
+ ->setChar($char)
->setCode($matches[2])
->setSeverity($this->getLintMessageSeverity($matches[2]))
->setName(ucwords(str_replace('-', ' ', $matches[3])))
->setDescription($matches[4]);
$messages[] = $message;
}
return $messages;
}
protected function getDefaultMessageSeverity($code) {
switch (substr($code, 0, 1)) {
case 'R':
case 'C':
return ArcanistLintSeverity::SEVERITY_ADVICE;
case 'W':
return ArcanistLintSeverity::SEVERITY_WARNING;
case 'E':
case 'F':
return ArcanistLintSeverity::SEVERITY_ERROR;
default:
return ArcanistLintSeverity::SEVERITY_DISABLED;
}
}
protected function getLintCodeFromLinterConfigurationKey($code) {
if (!preg_match('/^(R|C|W|E|F)\d{4}$/', $code)) {
throw new Exception(
pht(
'Unrecognized lint message code "%s". Expected a valid Pylint '.
'lint code like "%s", or "%s", or "%s".',
$code,
'C0111',
'E0602',
'W0611'));
}
return $code;
}
}
diff --git a/src/lint/linter/__tests__/pylint/negativechar.lint-test b/src/lint/linter/__tests__/pylint/negativechar.lint-test
new file mode 100644
index 00000000..18b69adb
--- /dev/null
+++ b/src/lint/linter/__tests__/pylint/negativechar.lint-test
@@ -0,0 +1,6 @@
+"""Docstring"""
+
+"""
+Useless string """
+~~~~~~~~~~
+warning:4:0 See T9257.
diff --git a/src/lint/linter/__tests__/xml/attr3.lint-test b/src/lint/linter/__tests__/xml/attr3.lint-test
deleted file mode 100644
index b4010298..00000000
--- a/src/lint/linter/__tests__/xml/attr3.lint-test
+++ /dev/null
@@ -1,8 +0,0 @@
-<!DOCTYPE doc [
-<!ELEMENT doc (#PCDATA)>
-<!ATTLIST doc a1 CDATA "v1">
-<!ATTLIST doc a1 CDATA "z1">
-]>
-<doc></doc>
-~~~~~~~~~~
-warning:4:28
diff --git a/src/lint/linter/__tests__/xml/attr4.lint-test b/src/lint/linter/__tests__/xml/attr4.lint-test
deleted file mode 100644
index e2c02992..00000000
--- a/src/lint/linter/__tests__/xml/attr4.lint-test
+++ /dev/null
@@ -1,3 +0,0 @@
-<ROOT attr="XY"/>
-~~~~~~~~~~
-error:1:15
diff --git a/src/lint/linter/__tests__/xml/languages-7.lint-test b/src/lint/linter/__tests__/xml/languages-7.lint-test
deleted file mode 100644
index 54b4c98b..00000000
--- a/src/lint/linter/__tests__/xml/languages-7.lint-test
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<languages>
- <lang>
-</languages>
-~~~~~~~~~~
-error:4:7
-error:5:1
diff --git a/src/lint/linter/xhpast/rules/ArcanistPHPCompatibilityXHPASTLinterRule.php b/src/lint/linter/xhpast/rules/ArcanistPHPCompatibilityXHPASTLinterRule.php
index 5ee6f3fe..e2f627a3 100644
--- a/src/lint/linter/xhpast/rules/ArcanistPHPCompatibilityXHPASTLinterRule.php
+++ b/src/lint/linter/xhpast/rules/ArcanistPHPCompatibilityXHPASTLinterRule.php
@@ -1,450 +1,450 @@
<?php
final class ArcanistPHPCompatibilityXHPASTLinterRule
extends ArcanistXHPASTLinterRule {
const ID = 45;
public function getLintName() {
return pht('PHP Compatibility');
}
public function process(XHPASTNode $root) {
static $compat_info;
if (!$this->version) {
return;
}
if ($compat_info === null) {
$target = phutil_get_library_root('phutil').
'/../resources/php_compat_info.json';
$compat_info = phutil_json_decode(Filesystem::readFile($target));
}
// Create a whitelist for symbols which are being used conditionally.
$whitelist = array(
'class' => array(),
'function' => array(),
);
$conditionals = $root->selectDescendantsOfType('n_IF');
foreach ($conditionals as $conditional) {
$condition = $conditional->getChildOfType(0, 'n_CONTROL_CONDITION');
$function = $condition->getChildByIndex(0);
if ($function->getTypeName() != 'n_FUNCTION_CALL') {
continue;
}
$function_token = $function
->getChildByIndex(0);
if ($function_token->getTypeName() != 'n_SYMBOL_NAME') {
// This may be `Class::method(...)` or `$var(...)`.
continue;
}
$function_name = $function_token->getConcreteString();
switch ($function_name) {
case 'class_exists':
case 'function_exists':
case 'interface_exists':
$type = null;
switch ($function_name) {
case 'class_exists':
$type = 'class';
break;
case 'function_exists':
$type = 'function';
break;
case 'interface_exists':
$type = 'interface';
break;
}
$params = $function->getChildOfType(1, 'n_CALL_PARAMETER_LIST');
$symbol = $params->getChildByIndex(0);
if (!$symbol->isStaticScalar()) {
continue;
}
$symbol_name = $symbol->evalStatic();
if (!idx($whitelist[$type], $symbol_name)) {
$whitelist[$type][$symbol_name] = array();
}
$span = $conditional
->getChildByIndex(1)
->getTokens();
$whitelist[$type][$symbol_name][] = range(
head_key($span),
last_key($span));
break;
}
}
$calls = $root->selectDescendantsOfType('n_FUNCTION_CALL');
foreach ($calls as $call) {
$node = $call->getChildByIndex(0);
$name = $node->getConcreteString();
$version = idx($compat_info['functions'], $name, array());
$min = idx($version, 'php.min');
$max = idx($version, 'php.max');
// Check if whitelisted.
$whitelisted = false;
foreach (idx($whitelist['function'], $name, array()) as $range) {
if (array_intersect($range, array_keys($node->getTokens()))) {
$whitelisted = true;
break;
}
}
if ($whitelisted) {
continue;
}
if ($min && version_compare($min, $this->version, '>')) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s, but `%s()` was not '.
'introduced until PHP %s.',
$this->version,
$name,
$min));
} else if ($max && version_compare($max, $this->version, '<')) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s, but `%s()` was '.
'removed in PHP %s.',
$this->version,
$name,
$max));
} else if (array_key_exists($name, $compat_info['params'])) {
$params = $call->getChildOfType(1, 'n_CALL_PARAMETER_LIST');
foreach (array_values($params->getChildren()) as $i => $param) {
$version = idx($compat_info['params'][$name], $i);
if ($version && version_compare($version, $this->version, '>')) {
$this->raiseLintAtNode(
$param,
pht(
'This codebase targets PHP %s, but parameter %d '.
'of `%s()` was not introduced until PHP %s.',
$this->version,
$i + 1,
$name,
$version));
}
}
}
if ($this->windowsVersion) {
$windows = idx($compat_info['functions_windows'], $name);
if ($windows === false) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s on Windows, '.
'but `%s()` is not available there.',
$this->windowsVersion,
$name));
} else if (version_compare($windows, $this->windowsVersion, '>')) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s on Windows, '.
'but `%s()` is not available there until PHP %s.',
$this->windowsVersion,
$name,
$windows));
}
}
}
$classes = $root->selectDescendantsOfType('n_CLASS_NAME');
foreach ($classes as $node) {
$name = $node->getConcreteString();
$version = idx($compat_info['interfaces'], $name, array());
$version = idx($compat_info['classes'], $name, $version);
$min = idx($version, 'php.min');
$max = idx($version, 'php.max');
// Check if whitelisted.
$whitelisted = false;
foreach (idx($whitelist['class'], $name, array()) as $range) {
if (array_intersect($range, array_keys($node->getTokens()))) {
$whitelisted = true;
break;
}
}
if ($whitelisted) {
continue;
}
if ($min && version_compare($min, $this->version, '>')) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s, but `%s` was not '.
'introduced until PHP %s.',
$this->version,
$name,
$min));
} else if ($max && version_compare($max, $this->version, '<')) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s, but `%s` was '.
'removed in PHP %s.',
$this->version,
$name,
$max));
}
}
// TODO: Technically, this will include function names. This is unlikely to
// cause any issues (unless, of course, there existed a function that had
// the same name as some constant).
$constants = $root->selectDescendantsOfTypes(array(
'n_SYMBOL_NAME',
'n_MAGIC_SCALAR',
));
foreach ($constants as $node) {
$name = $node->getConcreteString();
$version = idx($compat_info['constants'], $name, array());
$min = idx($version, 'php.min');
$max = idx($version, 'php.max');
if ($min && version_compare($min, $this->version, '>')) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s, but `%s` was not '.
'introduced until PHP %s.',
$this->version,
$name,
$min));
} else if ($max && version_compare($max, $this->version, '<')) {
$this->raiseLintAtNode(
$node,
pht(
'This codebase targets PHP %s, but `%s` was '.
'removed in PHP %s.',
$this->version,
$name,
$max));
}
}
if (version_compare($this->version, '5.3.0') < 0) {
$this->lintPHP53Features($root);
} else {
$this->lintPHP53Incompatibilities($root);
}
if (version_compare($this->version, '5.4.0') < 0) {
$this->lintPHP54Features($root);
} else {
$this->lintPHP54Incompatibilities($root);
}
}
private function lintPHP53Features(XHPASTNode $root) {
$functions = $root->selectTokensOfType('T_FUNCTION');
foreach ($functions as $function) {
$next = $function->getNextToken();
while ($next) {
if ($next->isSemantic()) {
break;
}
$next = $next->getNextToken();
}
if ($next) {
if ($next->getTypeName() === '(') {
$this->raiseLintAtToken(
$function,
pht(
'This codebase targets PHP %s, but anonymous '.
'functions were not introduced until PHP 5.3.',
$this->version));
}
}
}
$namespaces = $root->selectTokensOfType('T_NAMESPACE');
foreach ($namespaces as $namespace) {
$this->raiseLintAtToken(
$namespace,
pht(
'This codebase targets PHP %s, but namespaces were not '.
'introduced until PHP 5.3.',
$this->version));
}
// NOTE: This is only "use x;", in anonymous functions the node type is
// n_LEXICAL_VARIABLE_LIST even though both tokens are T_USE.
// TODO: We parse n_USE in a slightly crazy way right now; that would be
// a better selector once it's fixed.
$uses = $root->selectDescendantsOfType('n_USE_LIST');
foreach ($uses as $use) {
$this->raiseLintAtNode(
$use,
pht(
'This codebase targets PHP %s, but namespaces were not '.
'introduced until PHP 5.3.',
$this->version));
}
$statics = $root->selectDescendantsOfType('n_CLASS_STATIC_ACCESS');
foreach ($statics as $static) {
$name = $static->getChildByIndex(0);
if ($name->getTypeName() != 'n_CLASS_NAME') {
continue;
}
if ($name->getConcreteString() === 'static') {
$this->raiseLintAtNode(
$name,
pht(
'This codebase targets PHP %s, but `%s` was not '.
'introduced until PHP 5.3.',
$this->version,
'static::'));
}
}
$ternaries = $root->selectDescendantsOfType('n_TERNARY_EXPRESSION');
foreach ($ternaries as $ternary) {
- $yes = $ternary->getChildByIndex(1);
+ $yes = $ternary->getChildByIndex(2);
if ($yes->getTypeName() === 'n_EMPTY') {
$this->raiseLintAtNode(
$ternary,
pht(
'This codebase targets PHP %s, but short ternary was '.
'not introduced until PHP 5.3.',
$this->version));
}
}
$heredocs = $root->selectDescendantsOfType('n_HEREDOC');
foreach ($heredocs as $heredoc) {
if (preg_match('/^<<<[\'"]/', $heredoc->getConcreteString())) {
$this->raiseLintAtNode(
$heredoc,
pht(
'This codebase targets PHP %s, but nowdoc was not '.
'introduced until PHP 5.3.',
$this->version));
}
}
}
private function lintPHP53Incompatibilities(XHPASTNode $root) {}
private function lintPHP54Features(XHPASTNode $root) {
$indexes = $root->selectDescendantsOfType('n_INDEX_ACCESS');
foreach ($indexes as $index) {
switch ($index->getChildByIndex(0)->getTypeName()) {
case 'n_FUNCTION_CALL':
case 'n_METHOD_CALL':
$this->raiseLintAtNode(
$index->getChildByIndex(1),
pht(
'The `%s` syntax was not introduced until PHP 5.4, but this '.
'codebase targets an earlier version of PHP. You can rewrite '.
'this expression using `%s`.',
'f()[...]',
'idx()'));
break;
}
}
$closures = $this->getAnonymousClosures($root);
foreach ($closures as $closure) {
$static_accesses = $closure
->selectDescendantsOfType('n_CLASS_STATIC_ACCESS');
foreach ($static_accesses as $static_access) {
$class = $static_access->getChildByIndex(0);
if ($class->getTypeName() != 'n_CLASS_NAME') {
continue;
}
if (strtolower($class->getConcreteString()) != 'self') {
continue;
}
$this->raiseLintAtNode(
$class,
pht(
'The use of `%s` in an anonymous closure is not '.
'available before PHP 5.4.',
'self'));
}
$property_accesses = $closure
->selectDescendantsOfType('n_OBJECT_PROPERTY_ACCESS');
foreach ($property_accesses as $property_access) {
$variable = $property_access->getChildByIndex(0);
if ($variable->getTypeName() != 'n_VARIABLE') {
continue;
}
if ($variable->getConcreteString() != '$this') {
continue;
}
$this->raiseLintAtNode(
$variable,
pht(
'The use of `%s` in an anonymous closure is not '.
'available before PHP 5.4.',
'$this'));
}
}
}
private function lintPHP54Incompatibilities(XHPASTNode $root) {
$breaks = $root->selectDescendantsOfTypes(array('n_BREAK', 'n_CONTINUE'));
foreach ($breaks as $break) {
$arg = $break->getChildByIndex(0);
switch ($arg->getTypeName()) {
case 'n_EMPTY':
break;
case 'n_NUMERIC_SCALAR':
if ($arg->getConcreteString() != '0') {
break;
}
default:
$this->raiseLintAtNode(
$break->getChildByIndex(0),
pht(
'The `%s` and `%s` statements no longer accept '.
'variable arguments.',
'break',
'continue'));
break;
}
}
}
}
diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php
index b9c6185f..f5ceae9c 100644
--- a/src/repository/api/ArcanistGitAPI.php
+++ b/src/repository/api/ArcanistGitAPI.php
@@ -1,1255 +1,1302 @@
<?php
/**
* Interfaces with Git working copies.
*/
final class ArcanistGitAPI extends ArcanistRepositoryAPI {
private $repositoryHasNoCommits = false;
const SEARCH_LENGTH_FOR_PARENT_REVISIONS = 16;
/**
* For the repository's initial commit, 'git diff HEAD^' and similar do
* not work. Using this instead does work; it is the hash of the empty tree.
*/
const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
private $symbolicHeadCommit;
private $resolvedHeadCommit;
protected function buildLocalFuture(array $argv) {
$argv[0] = 'git '.$argv[0];
$future = newv('ExecFuture', $argv);
$future->setCWD($this->getPath());
return $future;
}
public function execPassthru($pattern /* , ... */) {
$args = func_get_args();
static $git = null;
if ($git === null) {
if (phutil_is_windows()) {
// NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because
// everything goes to hell if we don't. We must provide an absolute
// path to Git for this to work properly.
$git = Filesystem::resolveBinary('git');
$git = csprintf('%s', $git);
} else {
$git = 'git';
}
}
$args[0] = $git.' '.$args[0];
return call_user_func_array('phutil_passthru', $args);
}
public function getSourceControlSystemName() {
return 'git';
}
+ public function getGitVersion() {
+ list($stdout) = $this->execxLocal('--version');
+ return rtrim(str_replace('git version ', '', $stdout));
+ }
+
public function getMetadataPath() {
static $path = null;
if ($path === null) {
list($stdout) = $this->execxLocal('rev-parse --git-dir');
$path = rtrim($stdout, "\n");
// the output of git rev-parse --git-dir is an absolute path, unless
// the cwd is the root of the repository, in which case it uses the
// relative path of .git. If we get this relative path, turn it into
// an absolute path.
if ($path === '.git') {
$path = $this->getPath('.git');
}
}
return $path;
}
public function getHasCommits() {
return !$this->repositoryHasNoCommits;
}
/**
* Tests if a child commit is descendant of a parent commit.
* If child and parent are the same, it returns false.
* @param Child commit SHA.
* @param Parent commit SHA.
* @return bool True if the child is a descendant of the parent.
*/
private function isDescendant($child, $parent) {
list($common_ancestor) = $this->execxLocal(
'merge-base %s %s',
$child,
$parent);
$common_ancestor = trim($common_ancestor);
return ($common_ancestor == $parent) && ($common_ancestor != $child);
}
public function getLocalCommitInformation() {
if ($this->repositoryHasNoCommits) {
// Zero commits.
throw new Exception(
pht(
"You can't get local commit information for a repository with no ".
"commits."));
} else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) {
// One commit.
$against = 'HEAD';
} else {
// 2..N commits. We include commits reachable from HEAD which are
// not reachable from the base commit; this is consistent with user
// expectations even though it is not actually the diff range.
// Particularly:
//
// |
// D <----- master branch
// |
// C Y <- feature branch
// | /|
// B X
// | /
// A
// |
//
// If "A, B, C, D" are master, and the user is at Y, when they run
// "arc diff B" they want (and get) a diff of B vs Y, but they think about
// this as being the commits X and Y. If we log "B..Y", we only show
// Y. With "Y --not B", we show X and Y.
if ($this->symbolicHeadCommit !== null) {
$base_commit = $this->getBaseCommit();
$resolved_base = $this->resolveCommit($base_commit);
$head_commit = $this->symbolicHeadCommit;
$resolved_head = $this->getHeadCommit();
if (!$this->isDescendant($resolved_head, $resolved_base)) {
// NOTE: Since the base commit will have been resolved as the
// merge-base of the specified base and the specified HEAD, we can't
// easily tell exactly what's wrong with the range.
// For example, `arc diff HEAD --head HEAD^^^` is invalid because it
// is reversed, but resolving the commit "HEAD" will compute its
// merge-base with "HEAD^^^", which is "HEAD^^^", so the range will
// appear empty.
throw new ArcanistUsageException(
pht(
'The specified commit range is empty, backward or invalid: the '.
'base (%s) is not an ancestor of the head (%s). You can not '.
'diff an empty or reversed commit range.',
$base_commit,
$head_commit));
}
}
$against = csprintf(
'%s --not %s',
$this->getHeadCommit(),
$this->getBaseCommit());
}
// NOTE: Windows escaping of "%" symbols apparently is inherently broken;
// when passed through escapeshellarg() they are replaced with spaces.
// TODO: Learn how cmd.exe works and find some clever workaround?
// NOTE: If we use "%x00", output is truncated in Windows.
list($info) = $this->execxLocal(
phutil_is_windows()
? 'log %C --format=%C --'
: 'log %C --format=%s --',
$against,
// NOTE: "%B" is somewhat new, use "%s%n%n%b" instead.
'%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02');
$commits = array();
$info = trim($info, " \n\2");
if (!strlen($info)) {
return array();
}
$info = explode("\2", $info);
foreach ($info as $line) {
list($commit, $tree, $parents, $time, $author, $author_email,
$title, $message) = explode("\1", trim($line), 8);
$message = rtrim($message);
$commits[$commit] = array(
'commit' => $commit,
'tree' => $tree,
'parents' => array_filter(explode(' ', $parents)),
'time' => $time,
'author' => $author,
'summary' => $title,
'message' => $message,
'authorEmail' => $author_email,
);
}
return $commits;
}
protected function buildBaseCommit($symbolic_commit) {
if ($symbolic_commit !== null) {
if ($symbolic_commit == self::GIT_MAGIC_ROOT_COMMIT) {
$this->setBaseCommitExplanation(
pht('you explicitly specified the empty tree.'));
return $symbolic_commit;
}
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s %s',
$symbolic_commit,
$this->getHeadCommit());
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
if ($this->symbolicHeadCommit === null) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and HEAD.",
$symbolic_commit));
} else {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and the explicitly specified head commit '%s'.",
$symbolic_commit,
$this->symbolicHeadCommit));
}
return trim($merge_base);
}
// Detect zero-commit or one-commit repositories. There is only one
// relative-commit value that makes any sense in these repositories: the
// empty tree.
list($err) = $this->execManualLocal('rev-parse --verify HEAD^');
if ($err) {
list($err) = $this->execManualLocal('rev-parse --verify HEAD');
if ($err) {
$this->repositoryHasNoCommits = true;
}
if ($this->repositoryHasNoCommits) {
$this->setBaseCommitExplanation(pht('the repository has no commits.'));
} else {
$this->setBaseCommitExplanation(
pht('the repository has only one commit.'));
}
return self::GIT_MAGIC_ROOT_COMMIT;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getConfigurationManager()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
pht(
"None of the rules in your 'base' configuration matched a valid ".
"commit. Adjust rules or specify which commit you want to use ".
"explicitly."));
}
return $base;
}
$do_write = false;
$default_relative = null;
$working_copy = $this->getWorkingCopyIdentity();
if ($working_copy) {
$default_relative = $working_copy->getProjectConfig(
'git.default-relative-commit');
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s' in ".
"'%s'. This setting overrides other settings.",
$default_relative,
'git.default-relative-commit',
'.arcconfig'));
}
if (!$default_relative) {
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$default_relative = trim($upstream);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' (the Git upstream ".
"of the current branch) HEAD.",
$default_relative));
}
}
if (!$default_relative) {
$default_relative = $this->readScratchFile('default-relative-commit');
$default_relative = trim($default_relative);
if ($default_relative) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s'.",
$default_relative,
'.git/arc/default-relative-commit'));
}
}
if (!$default_relative) {
// TODO: Remove the history lesson soon.
echo phutil_console_format(
"<bg:green>** %s **</bg>\n\n",
pht('Select a Default Commit Range'));
echo phutil_console_wrap(
pht(
"You're running a command which operates on a range of revisions ".
"(usually, from some revision to HEAD) but have not specified the ".
"revision that should determine the start of the range.\n\n".
"Previously, arc assumed you meant '%s' when you did not specify ".
"a start revision, but this behavior does not make much sense in ".
"most workflows outside of Facebook's historic %s workflow.\n\n".
"arc no longer assumes '%s'. You must specify a relative commit ".
"explicitly when you invoke a command (e.g., `%s`, not just `%s`) ".
"or select a default for this working copy.\n\nIn most cases, the ".
"best default is '%s'. You can also select '%s' to preserve the ".
"old behavior, or some other remote or branch. But you almost ".
"certainly want to select 'origin/master'.\n\n".
"(Technically: the merge-base of the selected revision and HEAD is ".
"used to determine the start of the commit range.)",
'HEAD^',
'git-svn',
'HEAD^',
'arc diff HEAD^',
'arc diff',
'origin/master',
'HEAD^'));
$prompt = pht('What default do you want to use? [origin/master]');
$default = phutil_console_prompt($prompt);
if (!strlen(trim($default))) {
$default = 'origin/master';
}
$default_relative = $default;
$do_write = true;
}
list($object_type) = $this->execxLocal(
'cat-file -t %s',
$default_relative);
if (trim($object_type) !== 'commit') {
throw new Exception(
pht(
"Relative commit '%s' is not the name of a commit!",
$default_relative));
}
if ($do_write) {
// Don't perform this write until we've verified that the object is a
// valid commit name.
$this->writeScratchFile('default-relative-commit', $default_relative);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as you just specified.",
$default_relative));
}
list($merge_base) = $this->execxLocal(
'merge-base %s HEAD',
$default_relative);
return trim($merge_base);
}
public function getHeadCommit() {
if ($this->resolvedHeadCommit === null) {
$this->resolvedHeadCommit = $this->resolveCommit(
coalesce($this->symbolicHeadCommit, 'HEAD'));
}
return $this->resolvedHeadCommit;
}
public function setHeadCommit($symbolic_commit) {
$this->symbolicHeadCommit = $symbolic_commit;
$this->reloadCommitRange();
return $this;
}
/**
* Translates a symbolic commit (like "HEAD^") to a commit identifier.
* @param string_symbol commit.
* @return string the commit SHA.
*/
private function resolveCommit($symbolic_commit) {
list($err, $commit_hash) = $this->execManualLocal(
'rev-parse %s',
$symbolic_commit);
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
return trim($commit_hash);
}
private function getDiffFullOptions($detect_moves_and_renames = true) {
$options = array(
self::getDiffBaseOptions(),
'--no-color',
'--src-prefix=a/',
'--dst-prefix=b/',
'-U'.$this->getDiffLinesOfContext(),
);
if ($detect_moves_and_renames) {
$options[] = '-M';
$options[] = '-C';
}
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);
}
/**
* @param the base revision
* @param head revision. If this is null, the generated diff will include the
* working copy
*/
public function getFullGitDiff($base, $head = null) {
$options = $this->getDiffFullOptions();
if ($head !== null) {
list($stdout) = $this->execxLocal(
"diff {$options} %s %s --",
$base,
$head);
} else {
list($stdout) = $this->execxLocal(
"diff {$options} %s --",
$base);
}
return $stdout;
}
/**
* @param string Path to generate a diff for.
* @param bool If true, detect moves and renames. Otherwise, ignore
* moves/renames; this is useful because it prompts git to
* generate real diff text.
*/
public function getRawDiffText($path, $detect_moves_and_renames = true) {
$options = $this->getDiffFullOptions($detect_moves_and_renames);
list($stdout) = $this->execxLocal(
"diff {$options} %s -- %s",
$this->getBaseCommit(),
$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) = $this->execxLocal('branch --no-color');
-
- // Assume that any branch beginning with '(' means 'no branch', or whatever
- // 'no branch' is in the current locale.
- $matches = null;
- if (preg_match('/^\* ([^\(].*)$/m', $stdout, $matches)) {
- return $matches[1];
+ private function getBranchNameFromRef($ref) {
+ $count = 0;
+ $branch = preg_replace('/^refs\/heads\//', '', $ref, 1, $count);
+ if ($count !== 1) {
+ return null;
}
- return null;
+ return $branch;
+ }
+
+ public function getBranchName() {
+ list($err, $stdout, $stderr) = $this->execManualLocal(
+ 'symbolic-ref --quiet HEAD');
+
+ if ($err === 0) {
+ // We expect the branch name to come qualified with a refs/heads/ prefix.
+ // Verify this, and strip it.
+ $ref = rtrim($stdout);
+ $branch = $this->getBranchNameFromRef($ref);
+ if (!$branch) {
+ throw new Exception(
+ pht('Failed to parse %s output!', 'git symbolic-ref'));
+ }
+ return $branch;
+ } else if ($err === 1) {
+ // Exit status 1 with --quiet indicates that HEAD is detached.
+ return null;
+ } else {
+ throw new Exception(
+ pht('Command %s failed: %s', 'git symbolic-ref', $stderr));
+ }
}
public function getRemoteURI() {
- list($stdout) = $this->execxLocal('remote show -n origin');
+ // "git ls-remote --get-url" is the appropriate plumbing to get the remote
+ // URI. "git config remote.origin.url", on the other hand, may not be as
+ // accurate (for example, it does not take into account possible URL
+ // rewriting rules set by the user through "url.<base>.insteadOf"). However,
+ // the --get-url flag requires git 1.7.5.
+ $version = $this->getGitVersion();
+ if (version_compare($version, '1.7.5', '>=')) {
+ list($stdout) = $this->execxLocal('ls-remote --get-url origin');
+ } else {
+ list($stdout) = $this->execxLocal('config remote.origin.url');
+ }
- $matches = null;
- if (preg_match('/^\s*Fetch URL: (.*)$/m', $stdout, $matches)) {
- return trim($matches[1]);
+ $uri = rtrim($stdout);
+ // 'origin' is what ls-remote outputs if no origin remote URI exists
+ if (!$uri || $uri === 'origin') {
+ return null;
}
- return null;
+ return $uri;
}
public function getSourceControlPath() {
// TODO: Try to get something useful here.
return null;
}
public function getGitCommitLog() {
$relative = $this->getBaseCommit();
if ($this->repositoryHasNoCommits) {
// No commits yet.
return '';
} else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) {
// First commit.
list($stdout) = $this->execxLocal(
'log --format=medium HEAD');
} else {
// 2..N commits.
list($stdout) = $this->execxLocal(
'log --first-parent --format=medium %s..%s',
$this->getBaseCommit(),
$this->getHeadCommit());
}
return $stdout;
}
public function getGitHistoryLog() {
list($stdout) = $this->execxLocal(
'log --format=medium -n%d %s',
self::SEARCH_LENGTH_FOR_PARENT_REVISIONS,
$this->getBaseCommit());
return $stdout;
}
public function getSourceControlBaseRevision() {
list($stdout) = $this->execxLocal(
'rev-parse %s',
$this->getBaseCommit());
return rtrim($stdout, "\n");
}
public function getCanonicalRevisionName($string) {
$match = null;
if (preg_match('/@([0-9]+)$/', $string, $match)) {
$stdout = $this->getHashFromFromSVNRevisionNumber($match[1]);
} else {
list($stdout) = $this->execxLocal(
phutil_is_windows()
? 'show -s --format=%C %s --'
: 'show -s --format=%s %s --',
'%H',
$string);
}
return rtrim($stdout);
}
private function executeSVNFindRev($input, $vcs) {
$match = array();
list($stdout) = $this->execxLocal(
'svn find-rev %s',
$input);
if (!$stdout) {
throw new ArcanistUsageException(
pht(
'Cannot find the %s equivalent of %s.',
$vcs,
$input));
}
// When git performs a partial-rebuild during svn
// look-up, we need to parse the final line
$lines = explode("\n", $stdout);
$stdout = $lines[count($lines) - 2];
return rtrim($stdout);
}
// Convert svn revision number to git hash
public function getHashFromFromSVNRevisionNumber($revision_id) {
return $this->executeSVNFindRev('r'.$revision_id, 'Git');
}
// Convert a git hash to svn revision number
public function getSVNRevisionNumberFromHash($hash) {
return $this->executeSVNFindRev($hash, 'SVN');
}
protected function buildUncommittedStatus() {
$diff_options = $this->getDiffBaseOptions();
if ($this->repositoryHasNoCommits) {
$diff_base = self::GIT_MAGIC_ROOT_COMMIT;
} else {
$diff_base = 'HEAD';
}
// Find uncommitted changes.
$uncommitted_future = $this->buildLocalFuture(
array(
'diff %C --raw %s --',
$diff_options,
$diff_base,
));
$untracked_future = $this->buildLocalFuture(
array(
'ls-files --others --exclude-standard',
));
// Unstaged changes
$unstaged_future = $this->buildLocalFuture(
array(
'diff-files --name-only',
));
$futures = array(
$uncommitted_future,
$untracked_future,
// NOTE: `git diff-files` races with each of these other commands
// internally, and resolves with inconsistent results if executed
// in parallel. To work around this, DO NOT run it at the same time.
// After the other commands exit, we can start the `diff-files` command.
);
id(new FutureIterator($futures))->resolveAll();
// We're clear to start the `git diff-files` now.
$unstaged_future->start();
$result = new PhutilArrayWithDefaultValue();
list($stdout) = $uncommitted_future->resolvex();
$uncommitted_files = $this->parseGitStatus($stdout);
foreach ($uncommitted_files as $path => $mask) {
$result[$path] |= ($mask | self::FLAG_UNCOMMITTED);
}
list($stdout) = $untracked_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNTRACKED;
}
}
list($stdout, $stderr) = $unstaged_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNSTAGED;
}
}
return $result->toArray();
}
protected function buildCommitRangeStatus() {
list($stdout, $stderr) = $this->execxLocal(
'diff %C --raw %s --',
$this->getDiffBaseOptions(),
$this->getBaseCommit());
return $this->parseGitStatus($stdout);
}
public function getGitConfig($key, $default = null) {
list($err, $stdout) = $this->execManualLocal('config %s', $key);
if ($err) {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT');
return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n"));
}
public function addToCommit(array $paths) {
$this->execxLocal(
'add -A -- %Ls',
$paths);
$this->reloadWorkingCopy();
return $this;
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
// NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4,
// so we do not provide it and thus require a message.
$this->execxLocal(
'commit -F %s',
$tmp_file);
$this->reloadWorkingCopy();
return $this;
}
public function amendCommit($message = null) {
if ($message === null) {
$this->execxLocal('commit --amend --allow-empty -C HEAD');
} else {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit --amend --allow-empty -F %s',
$tmp_file);
}
$this->reloadWorkingCopy();
return $this;
}
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, 6);
}
}
$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 getAllFiles() {
$future = $this->buildLocalFuture(array('ls-files -z'));
return id(new LinesOfALargeExecFuture($future))
->setDelimiter("\0");
}
public function getChangedFiles($since_commit) {
list($stdout) = $this->execxLocal(
'diff --raw %s',
$since_commit);
return $this->parseGitStatus($stdout);
}
public function getBlame($path) {
- // TODO: 'git blame' supports --porcelain and we should probably use it.
list($stdout) = $this->execxLocal(
- 'blame --date=iso -w -M %s -- %s',
+ 'blame --porcelain -w -M %s -- %s',
$this->getBaseCommit(),
$path);
- $blame = array();
- foreach (explode("\n", trim($stdout)) as $line) {
- if (!strlen($line)) {
- continue;
- }
+ // the --porcelain format prints at least one header line per source line,
+ // then the source line prefixed by a tab character
+ $blame_info = preg_split('/^\t.*\n/m', rtrim($stdout));
- // 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;
- }
+ // commit info is not repeated in these headers, so cache it
+ $revision_data = array();
- $matches = null;
- $ok = preg_match(
- '/^([0-9a-f]+)[^(]+?[(](.*?) +\d\d\d\d-\d\d-\d\d/',
- $line,
- $matches);
- if (!$ok) {
- throw new Exception(pht("Bad blame? `%s'", $line));
+ $blame = array();
+ foreach ($blame_info as $line_info) {
+ $revision = substr($line_info, 0, 40);
+ $data = idx($revision_data, $revision, array());
+
+ if (empty($data)) {
+ $matches = array();
+ if (!preg_match('/^author (.*)$/m', $line_info, $matches)) {
+ throw new Exception(
+ pht(
+ 'Unexpected output from %s: no author for commit %s',
+ 'git blame',
+ $revision));
+ }
+ $data['author'] = $matches[1];
+ $data['from_first_commit'] = preg_match('/^boundary$/m', $line_info);
+ $revision_data[$revision] = $data;
}
- $revision = $matches[1];
- $author = $matches[2];
- $blame[] = array($author, $revision);
+ // Ignore lines predating the git repository (on a boundary commit)
+ // rather than blaming them on the oldest diff's unfortunate author
+ if (!$data['from_first_commit']) {
+ $blame[] = array($data['author'], $revision);
+ }
}
return $blame;
}
public function getOriginalFileData($path) {
return $this->getFileDataAtRevision($path, $this->getBaseCommit());
}
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|commit) ([a-z0-9]{40})[\t](.*)$/',
$line,
$matches);
if (!$ok) {
throw new Exception(pht('Failed to parse %s output!', 'git ls-tree'));
}
$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) = $this->execxLocal(
'ls-tree %s -- %s',
$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) = $this->execxLocal(
'cat-file blob %s',
$info[$path]['ref']);
return $stdout;
}
/**
* Returns names of all the branches in the current repository.
*
* @return list<dict<string, string>> Dictionary of branch information.
*/
public function getAllBranches() {
- list($branch_info) = $this->execxLocal(
- 'branch --no-color');
- $lines = explode("\n", rtrim($branch_info));
+ list($ref_list) = $this->execxLocal(
+ 'for-each-ref --format=%s refs/heads',
+ '%(refname)');
+ $refs = explode("\n", rtrim($ref_list));
+ $current = $this->getBranchName();
$result = array();
- foreach ($lines as $line) {
-
- if (preg_match('@^[* ]+\(no branch|detached from \w+/\w+\)@', $line)) {
- // This is indicating that the working copy is in a detached state;
- // just ignore it.
- continue;
+ foreach ($refs as $ref) {
+ $branch = $this->getBranchNameFromRef($ref);
+ if ($branch) {
+ $result[] = array(
+ 'current' => ($branch === $current),
+ 'name' => $branch,
+ );
}
-
- list($current, $name) = preg_split('/\s+/', $line, 2);
- $result[] = array(
- 'current' => !empty($current),
- 'name' => $name,
- );
}
return $result;
}
public function getWorkingCopyRevision() {
list($stdout) = $this->execxLocal('rev-parse HEAD');
return rtrim($stdout, "\n");
}
public function getUnderlyingWorkingCopyRevision() {
list($err, $stdout) = $this->execManualLocal('svn find-rev HEAD');
if (!$err && $stdout) {
return rtrim($stdout, "\n");
}
return $this->getWorkingCopyRevision();
}
public function isHistoryDefaultImmutable() {
return false;
}
public function supportsAmend() {
return true;
}
public function supportsCommitRanges() {
return true;
}
public function supportsLocalCommits() {
return true;
}
public function hasLocalCommit($commit) {
try {
if (!$this->getCanonicalRevisionName($commit)) {
return false;
}
} catch (CommandException $exception) {
return false;
}
return true;
}
public function getAllLocalChanges() {
$diff = $this->getFullGitDiff($this->getBaseCommit());
if (!strlen(trim($diff))) {
return array();
}
$parser = new ArcanistDiffParser();
return $parser->parseDiff($diff);
}
public function supportsLocalBranchMerge() {
return true;
}
public function performLocalBranchMerge($branch, $message) {
if (!$branch) {
throw new ArcanistUsageException(
pht('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(pht('Merge failed!'));
}
}
public function getFinalizedRevisionMessage() {
return pht(
"You may now push this commit upstream, as appropriate (e.g. with ".
"'%s', or '%s', or by printing and faxing it).",
'git push',
'git svn dcommit');
}
public function getCommitMessage($commit) {
list($message) = $this->execxLocal(
'log -n1 --format=%C %s --',
'%s%n%n%b',
$commit);
return $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.
$reason_map = array();
$revision_ids = array();
foreach ($messages as $message) {
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$message->getMetadata('message'));
if ($object->getRevisionID()) {
$revision_ids[] = $object->getRevisionID();
$reason_map[$object->getRevisionID()] = $message->getCommitHash();
}
}
if ($revision_ids) {
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'ids' => $revision_ids,
));
foreach ($results as $key => $result) {
$hash = substr($reason_map[$result['id']], 0, 16);
$results[$key]['why'] = pht(
"Commit message for '%s' has explicit 'Differential Revision'.",
$hash);
}
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,
));
foreach ($results as $key => $result) {
$results[$key]['why'] = pht(
'A git commit or tree hash in the commit range is already attached '.
'to the Differential revision.');
}
return $results;
}
public function updateWorkingCopy() {
$this->execxLocal('pull');
$this->reloadWorkingCopy();
}
public function getCommitSummary($commit) {
if ($commit == self::GIT_MAGIC_ROOT_COMMIT) {
return pht('(The Empty Tree)');
}
list($summary) = $this->execxLocal(
'log -n 1 --format=%C %s',
'%s',
$commit);
return trim($summary);
}
public function backoutCommit($commit_hash) {
$this->execxLocal('revert %s -n --no-edit', $commit_hash);
$this->reloadWorkingCopy();
if (!$this->getUncommittedStatus()) {
throw new ArcanistUsageException(
pht('%s has already been reverted.', $commit_hash));
}
}
public function getBackoutMessage($commit_hash) {
return pht('This reverts commit %s.', $commit_hash);
}
public function isGitSubversionRepo() {
return Filesystem::pathExists($this->getPath('.git/svn'));
}
public function resolveBaseCommitRule($rule, $source) {
list($type, $name) = explode(':', $rule, 2);
switch ($type) {
case 'git':
$matches = null;
if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s HEAD',
$matches[1]);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified by ".
"'%s' in your %s 'base' configuration.",
$matches[1],
$rule,
$source));
return trim($merge_base);
}
} else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s HEAD',
$matches[1]);
if ($err) {
return null;
}
$merge_base = trim($merge_base);
list($commits) = $this->execxLocal(
'log --format=%C %s..HEAD --',
'%H',
$merge_base);
$commits = array_filter(explode("\n", $commits));
if (!$commits) {
return null;
}
$commits[] = $merge_base;
$head_branch_count = null;
+ $all_branch_names = ipull($this->getAllBranches(), 'name');
foreach ($commits as $commit) {
+ // Ideally, we would use something like "for-each-ref --contains"
+ // to get a filtered list of branches ready for script consumption.
+ // Instead, try to get predictable output from "branch --contains".
list($branches) = $this->execxLocal(
- 'branch --contains %s',
+ '-c column.ui=never -c color.ui=never branch --contains %s',
$commit);
$branches = array_filter(explode("\n", $branches));
+
+ // Filter the list, removing the "current" marker (*) and ignoring
+ // anything other than known branch names (mainly, any possible
+ // "detached HEAD" or "no branch" line).
+ foreach ($branches as $key => $branch) {
+ $branch = trim($branch, ' *');
+ if (in_array($branch, $all_branch_names)) {
+ $branches[$key] = $branch;
+ } else {
+ unset($branches[$key]);
+ }
+ }
+
if ($head_branch_count === null) {
// If this is the first commit, it's HEAD. Count how many
// branches it is on; we want to include commits on the same
// number of branches. This covers a case where this branch
// has sub-branches and we're running "arc diff" here again
// for whatever reason.
$head_branch_count = count($branches);
} else if (count($branches) > $head_branch_count) {
- foreach ($branches as $key => $branch) {
- $branches[$key] = trim($branch, ' *');
- }
$branches = implode(', ', $branches);
$this->setBaseCommitExplanation(
pht(
"it is the first commit between '%s' (the merge-base of ".
"'%s' and HEAD) which is also contained by another branch ".
"(%s).",
$merge_base,
$matches[1],
$branches));
return $commit;
}
}
} else {
list($err) = $this->execManualLocal(
'cat-file -t %s',
$name);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return $name;
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return self::GIT_MAGIC_ROOT_COMMIT;
case 'amended':
$text = $this->getCommitMessage('HEAD');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"HEAD has been amended with 'Differential Revision:', ".
"as specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
break;
case 'upstream':
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$upstream = rtrim($upstream);
list($upstream_merge_base) = $this->execxLocal(
'merge-base %s HEAD',
$upstream);
$upstream_merge_base = rtrim($upstream_merge_base);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the upstream of the current branch ".
"and HEAD, and matched the rule '%s' in your %s ".
"'base' configuration.",
$rule,
$source));
return $upstream_merge_base;
}
break;
case 'this':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
default:
return null;
}
return null;
}
public function canStashChanges() {
return true;
}
public function stashChanges() {
$this->execxLocal('stash');
$this->reloadWorkingCopy();
}
public function unstashChanges() {
$this->execxLocal('stash pop');
}
protected function didReloadCommitRange() {
// After an amend, the symbolic head may resolve to a different commit.
$this->resolvedHeadCommit = null;
}
}
diff --git a/src/workflow/ArcanistLintersWorkflow.php b/src/workflow/ArcanistLintersWorkflow.php
index 6d70b4d6..a521a586 100644
--- a/src/workflow/ArcanistLintersWorkflow.php
+++ b/src/workflow/ArcanistLintersWorkflow.php
@@ -1,215 +1,292 @@
<?php
/**
* List available linters.
*/
final class ArcanistLintersWorkflow extends ArcanistWorkflow {
public function getWorkflowName() {
return 'linters';
}
public function getCommandSynopses() {
return phutil_console_format(<<<EOTEXT
- **linters** [__options__]
+ **linters** [__options__] [__name__]
EOTEXT
);
}
public function getCommandHelp() {
return phutil_console_format(pht(<<<EOTEXT
Supports: cli
List the available and configured linters, with information about
what they do and which versions are installed.
+
+ if __name__ is provided, the linter with that name will be displayed.
EOTEXT
));
}
public function getArguments() {
return array(
'verbose' => array(
'help' => pht('Show detailed information, including options.'),
),
+ 'search' => array(
+ 'param' => 'search',
+ 'repeat' => true,
+ 'help' => pht(
+ 'Search for linters. Search is case-insensitive, and is performed'.
+ 'against name and description of each linter.'),
+ ),
+ '*' => 'exact',
);
}
public function run() {
$console = PhutilConsole::getConsole();
$linters = id(new PhutilClassMapQuery())
->setAncestorClass('ArcanistLinter')
->execute();
try {
$built = $this->newLintEngine()->buildLinters();
} catch (ArcanistNoEngineException $ex) {
$built = array();
}
- // Note that an engine can emit multiple linters of the same class to run
- // different rulesets on different groups of files, so these linters do not
- // necessarily have unique classes or types.
- $groups = array();
- foreach ($built as $linter) {
- $groups[get_class($linter)][] = $linter;
- }
-
- $linter_info = array();
- foreach ($linters as $key => $linter) {
- $installed = idx($groups, $key, array());
- $exception = null;
-
- if ($installed) {
- $status = 'configured';
- try {
- $version = head($installed)->getVersion();
- } catch (Exception $ex) {
- $status = 'error';
- $exception = $ex;
- }
- } else {
- $status = 'available';
- $version = null;
- }
-
- $linter_info[$key] = array(
- 'short' => $linter->getLinterConfigurationName(),
- 'class' => get_class($linter),
- 'status' => $status,
- 'version' => $version,
- 'name' => $linter->getInfoName(),
- 'uri' => $linter->getInfoURI(),
- 'description' => $linter->getInfoDescription(),
- 'exception' => $exception,
- 'options' => $linter->getLinterConfigurationOptions(),
- );
- }
-
- $linter_info = isort($linter_info, 'short');
+ $linter_info = $this->getLintersInfo($linters, $built);
$status_map = $this->getStatusMap();
$pad = ' ';
$color_map = array(
'configured' => 'green',
'available' => 'yellow',
'error' => 'red',
);
+ $is_verbose = $this->getArgument('verbose');
+ $exact = $this->getArgument('exact');
+ $search_terms = $this->getArgument('search');
+
+ if ($exact && $search_terms) {
+ throw new ArcanistUsageException(
+ 'Specify either search expression or exact name');
+ }
+
+ if ($exact) {
+ $linter_info = $this->findExactNames($linter_info, $exact);
+ if (!$linter_info) {
+ $console->writeOut(
+ "%s\n",
+ pht(
+ 'No match found. Try `%s %s` to search for a linter.',
+ 'arc linters --search',
+ $exact[0]));
+ return;
+ }
+ $is_verbose = true;
+ }
+
+ if ($search_terms) {
+ $linter_info = $this->filterByNames($linter_info, $search_terms);
+ }
+
+
foreach ($linter_info as $key => $linter) {
$status = $linter['status'];
$color = $color_map[$status];
$text = $status_map[$status];
$print_tail = false;
$console->writeOut(
"<bg:".$color.">** %s **</bg> **%s** (%s)\n",
$text,
nonempty($linter['short'], '-'),
$linter['name']);
if ($linter['exception']) {
$console->writeOut(
"\n%s**%s**\n%s\n",
$pad,
get_class($linter['exception']),
phutil_console_wrap(
$linter['exception']->getMessage(),
strlen($pad)));
$print_tail = true;
}
- $version = $linter['version'];
- $uri = $linter['uri'];
- if ($version || $uri) {
- $console->writeOut("\n");
- $print_tail = true;
- }
-
- if ($version) {
- $console->writeOut("%s%s **%s**\n", $pad, pht('Version'), $version);
- }
-
- if ($uri) {
- $console->writeOut("%s__%s__\n", $pad, $linter['uri']);
- }
+ if ($is_verbose) {
+ $version = $linter['version'];
+ $uri = $linter['uri'];
+ if ($version || $uri) {
+ $console->writeOut("\n");
+ $print_tail = true;
+ }
- $description = $linter['description'];
- if ($description) {
- $console->writeOut(
- "\n%s\n",
- phutil_console_wrap($linter['description'], strlen($pad)));
- $print_tail = true;
- }
+ if ($version) {
+ $console->writeOut("%s%s **%s**\n", $pad, pht('Version'), $version);
+ }
- $options = $linter['options'];
- if ($options && $this->getArgument('verbose')) {
- $console->writeOut(
- "\n%s**%s**\n\n",
- $pad,
- pht('Configuration Options'));
+ if ($uri) {
+ $console->writeOut("%s__%s__\n", $pad, $linter['uri']);
+ }
- $last_option = last_key($options);
- foreach ($options as $option => $option_spec) {
+ $description = $linter['description'];
+ if ($description) {
$console->writeOut(
- "%s__%s__ (%s)\n",
- $pad,
- $option,
- $option_spec['type']);
+ "\n%s\n",
+ phutil_console_wrap($linter['description'], strlen($pad)));
+ $print_tail = true;
+ }
+ $options = $linter['options'];
+ if ($options) {
$console->writeOut(
- "%s\n",
- phutil_console_wrap(
- $option_spec['help'],
- strlen($pad) + 2));
-
- if ($option != $last_option) {
- $console->writeOut("\n");
+ "\n%s**%s**\n\n",
+ $pad,
+ pht('Configuration Options'));
+
+ $last_option = last_key($options);
+ foreach ($options as $option => $option_spec) {
+ $console->writeOut(
+ "%s__%s__ (%s)\n",
+ $pad,
+ $option,
+ $option_spec['type']);
+
+ $console->writeOut(
+ "%s\n",
+ phutil_console_wrap(
+ $option_spec['help'],
+ strlen($pad) + 2));
+
+ if ($option != $last_option) {
+ $console->writeOut("\n");
+ }
}
+ $print_tail = true;
}
- $print_tail = true;
- }
- if ($print_tail) {
- $console->writeOut("\n");
+ if ($print_tail) {
+ $console->writeOut("\n");
+ }
}
}
- if (!$this->getArgument('verbose')) {
+ if (!$is_verbose) {
$console->writeOut(
"%s\n",
pht('(Run `%s` for more details.)', 'arc linters --verbose'));
}
}
/**
* Get human-readable linter statuses, padded to fixed width.
*
* @return map<string, string> Human-readable linter status names.
*/
private function getStatusMap() {
$text_map = array(
'configured' => pht('CONFIGURED'),
'available' => pht('AVAILABLE'),
'error' => pht('ERROR'),
);
$sizes = array();
foreach ($text_map as $key => $string) {
$sizes[$key] = phutil_utf8_console_strlen($string);
}
$longest = max($sizes);
foreach ($text_map as $key => $string) {
if ($sizes[$key] < $longest) {
$text_map[$key] .= str_repeat(' ', $longest - $sizes[$key]);
}
}
$text_map['padding'] = str_repeat(' ', $longest);
return $text_map;
}
+ private function getLintersInfo(array $linters, array $built) {
+ // Note that an engine can emit multiple linters of the same class to run
+ // different rulesets on different groups of files, so these linters do not
+ // necessarily have unique classes or types.
+ $groups = array();
+ foreach ($built as $linter) {
+ $groups[get_class($linter)][] = $linter;
+ }
+
+ $linter_info = array();
+ foreach ($linters as $key => $linter) {
+ $installed = idx($groups, $key, array());
+ $exception = null;
+
+ if ($installed) {
+ $status = 'configured';
+ try {
+ $version = head($installed)->getVersion();
+ } catch (Exception $ex) {
+ $status = 'error';
+ $exception = $ex;
+ }
+ } else {
+ $status = 'available';
+ $version = null;
+ }
+
+ $linter_info[$key] = array(
+ 'short' => $linter->getLinterConfigurationName(),
+ 'class' => get_class($linter),
+ 'status' => $status,
+ 'version' => $version,
+ 'name' => $linter->getInfoName(),
+ 'uri' => $linter->getInfoURI(),
+ 'description' => $linter->getInfoDescription(),
+ 'exception' => $exception,
+ 'options' => $linter->getLinterConfigurationOptions(),
+ );
+ }
+
+ return isort($linter_info, 'short');
+ }
+
+ private function filterByNames(array $linters, array $search_terms) {
+ $filtered = array();
+
+ foreach ($linters as $key => $linter) {
+ $name = $linter['name'];
+ $short = $linter['short'];
+ $description = $linter['description'];
+ foreach ($search_terms as $term) {
+ if (stripos($name, $term) !== false ||
+ stripos($short, $term) !== false ||
+ stripos($description, $term) !== false) {
+ $filtered[$key] = $linter;
+ }
+ }
+ }
+ return $filtered;
+ }
+
+ private function findExactNames(array $linters, array $names) {
+ $filtered = array();
+
+ foreach ($linters as $key => $linter) {
+ $name = $linter['name'];
+
+ foreach ($names as $term) {
+ if (strcasecmp($name, $term) == 0) {
+ $filtered[$key] = $linter;
+ }
+ }
+ }
+ return $filtered;
+ }
+
}

File Metadata

Mime Type
application/octet-stream
Expires
Sat, Apr 27, 10:41 AM (2 d)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
6206615
Default Alt Text
(72 KB)

Event Timeline