Page MenuHomePhorge

No OneTemporary

diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php
index bcf0a876..4367a035 100644
--- a/src/repository/api/ArcanistGitAPI.php
+++ b/src/repository/api/ArcanistGitAPI.php
@@ -1,1016 +1,1021 @@
<?php
/**
* Interfaces with Git working copies.
*
* @group workingcopy
*/
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';
public static function newHookAPI($root) {
return new ArcanistGitAPI($root);
}
protected function buildLocalFuture(array $argv) {
$argv[0] = 'git '.$argv[0];
$future = newv('ExecFuture', $argv);
$future->setCWD($this->getPath());
return $future;
}
public function getSourceControlSystemName() {
return 'git';
}
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;
}
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->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 relative 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.
$against = csprintf('%s --not %s', 'HEAD', $this->getBaseCommit());
}
// NOTE: Windows escaping of "%" symbols apparently is inherently broken;
// when passed throuhgh 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 == ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) {
$this->setBaseCommitExplanation(
"you explicitly specified the empty tree.");
return $symbolic_commit;
}
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s HEAD',
$symbolic_commit);
if ($err) {
throw new ArcanistUsageException(
"Unable to find any git commit named '{$symbolic_commit}' in ".
"this repository.");
}
$this->setBaseCommitExplanation(
"it is the merge-base of '{$symbolic_commit}' and HEAD, as you ".
"explicitly specified.");
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(
"the repository has no commits.");
} else {
$this->setBaseCommitExplanation(
"the repository has only one commit.");
}
return self::GIT_MAGIC_ROOT_COMMIT;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getWorkingCopyIdentity()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
"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->getConfig(
'git.default-relative-commit');
$this->setBaseCommitExplanation(
"it is the merge-base of '{$default_relative}' and HEAD, as ".
"specified in 'git.default-relative-commit' in '.arcconfig'. This ".
"setting overrides other settings.");
}
if (!$default_relative) {
list($err, $upstream) = $this->execManualLocal(
"rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'");
if (!$err) {
$default_relative = trim($upstream);
$this->setBaseCommitExplanation(
"it is the merge-base of '{$default_relative}' (the Git upstream ".
"of the current branch) HEAD.");
}
}
if (!$default_relative) {
$default_relative = $this->readScratchFile('default-relative-commit');
$default_relative = trim($default_relative);
if ($default_relative) {
$this->setBaseCommitExplanation(
"it is the merge-base of '{$default_relative}' and HEAD, as ".
"specified in '.git/arc/default-relative-commit'.");
}
}
if (!$default_relative) {
// TODO: Remove the history lesson soon.
echo phutil_console_format(
"<bg:green>** Select a Default Commit Range **</bg>\n\n");
echo phutil_console_wrap(
"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 'HEAD^' when you did not specify ".
"a start revision, but this behavior does not make much sense in ".
"most workflows outside of Facebook's historic git-svn workflow.\n\n".
"arc no longer assumes 'HEAD^'. You must specify a relative commit ".
"explicitly when you invoke a command (e.g., `arc diff HEAD^`, not ".
"just `arc diff`) or select a default for this working copy.\n\n".
"In most cases, the best default is 'origin/master'. You can also ".
"select 'HEAD^' 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.)");
$prompt = "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(
"Relative commit '{$default_relative}' is not the name of a commit!");
}
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(
"it is the merge-base of '{$default_relative}' and HEAD, as you ".
"just specified.");
}
list($merge_base) = $this->execxLocal(
'merge-base %s HEAD',
$default_relative);
return trim($merge_base);
}
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);
}
public function getFullGitDiff() {
$options = $this->getDiffFullOptions();
list($stdout) = $this->execxLocal(
"diff {$options} %s --",
$this->getBaseCommit());
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];
}
return null;
}
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..HEAD',
$this->getBaseCommit());
}
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)) {
list($stdout) = $this->execxLocal(
'svn find-rev r%d',
$match[1]);
} else {
list($stdout) = $this->execxLocal(
'show -s --format=%C %s',
'%H',
$string);
}
return rtrim($stdout);
}
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',
));
// 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 = $this->buildLocalFuture(
array(
'ls-files -m',
));
$futures = array(
$uncommitted_future,
$untracked_future,
$unstaged_future,
);
Futures($futures)->resolveAll();
$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);
$this->execxLocal(
'commit --allow-empty-message -F %s',
$tmp_file);
$this->reloadWorkingCopy();
return $this;
}
- public function amendCommit($message) {
- $tmp_file = new TempFile();
- Filesystem::writeFile($tmp_file, $message);
- $this->execxLocal(
- 'commit --amend --allow-empty -F %s',
- $tmp_file);
+ 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;
}
public function getPreReceiveHookStatus($old_ref, $new_ref) {
$options = $this->getDiffBaseOptions();
list($stdout) = $this->execxLocal(
"diff {$options} --raw %s %s --",
$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, 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',
$this->getBaseCommit(),
$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->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) ([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) = $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));
$result = array();
foreach ($lines as $line) {
if (preg_match('/^[* ]+\(no branch\)/', $line)) {
// This is indicating that the working copy is in a detached state;
// just ignore it.
continue;
}
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();
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(
"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 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'] =
"Commit message for '{$hash}' has explicit 'Differential Revision'.";
}
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'] =
"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 '(The Empty Tree)';
}
list($summary) = $this->execxLocal(
'log -n 1 --format=%C %s',
'%s',
$commit);
return trim($summary);
}
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(
"it is the merge-base of '{$matches[1]}' and HEAD, as ".
"specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
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;
foreach ($commits as $commit) {
list($branches) = $this->execxLocal(
'branch --contains %s',
$commit);
$branches = array_filter(explode("\n", $branches));
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(
"it is the first commit between '{$merge_base}' (the ".
"merge-base of '{$matches[1]}' and HEAD) which is also ".
"contained by another branch ({$branches}).");
return $commit;
}
}
} else {
list($err) = $this->execManualLocal(
'cat-file -t %s',
$name);
if (!$err) {
$this->setBaseCommitExplanation(
"it is specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
return $name;
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
"you specified '{$rule}' in your {$source} 'base' ".
"configuration.");
return self::GIT_MAGIC_ROOT_COMMIT;
case 'amended':
$text = $this->getCommitMessage('HEAD');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
"HEAD has been amended with 'Differential Revision:', ".
"as specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
return 'HEAD^';
}
break;
case 'upstream':
list($err, $upstream) = $this->execManualLocal(
"rev-parse --abbrev-ref --symbolic-full-name '@{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(
"it is the merge-base of the upstream of the current branch ".
"and HEAD, and matched the rule '{$rule}' in your {$source} ".
"'base' configuration.");
return $upstream_merge_base;
}
break;
case 'this':
$this->setBaseCommitExplanation(
"you specified '{$rule}' in your {$source} 'base' ".
"configuration.");
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');
}
}
diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php
index 5f9eebb3..da82d3a3 100644
--- a/src/repository/api/ArcanistMercurialAPI.php
+++ b/src/repository/api/ArcanistMercurialAPI.php
@@ -1,1005 +1,1009 @@
<?php
/**
* Interfaces with the Mercurial working copies.
*
* @group workingcopy
*/
final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
private $branch;
private $localCommitInfo;
private $rawDiffCache = array();
private $supportsRebase;
private $supportsPhases;
protected function buildLocalFuture(array $argv) {
// Mercurial has a "defaults" feature which basically breaks automation by
// allowing the user to add random flags to any command. This feature is
// "deprecated" and "a bad idea" that you should "forget ... existed"
// according to project lead Matt Mackall:
//
// http://markmail.org/message/hl3d6eprubmkkqh5
//
// There is an HGPLAIN environmental variable which enables "plain mode"
// and hopefully disables this stuff.
if (phutil_is_windows()) {
$argv[0] = 'set HGPLAIN=1 & hg '.$argv[0];
} else {
$argv[0] = 'HGPLAIN=1 hg '.$argv[0];
}
$future = newv('ExecFuture', $argv);
$future->setCWD($this->getPath());
return $future;
}
public function execPassthru($pattern /* , ... */) {
$args = func_get_args();
if (phutil_is_windows()) {
$args[0] = 'set HGPLAIN=1 & hg '.$args[0];
} else {
$args[0] = 'HGPLAIN=1 hg '.$args[0];
}
return call_user_func_array("phutil_passthru", $args);
}
public function getSourceControlSystemName() {
return 'hg';
}
public function getMetadataPath() {
return $this->getPath('.hg');
}
public function getSourceControlBaseRevision() {
return $this->getCanonicalRevisionName($this->getBaseCommit());
}
public function getCanonicalRevisionName($string) {
$match = null;
if ($this->isHgSubversionRepo() &&
preg_match('/@([0-9]+)$/', $string, $match)) {
$string = hgsprintf('svnrev(%s)', $match[1]);
}
list($stdout) = $this->execxLocal(
'log -l 1 --template %s -r %s --',
'{node}',
$string);
return $stdout;
}
public function getSourceControlPath() {
return '/';
}
public function getBranchName() {
if (!$this->branch) {
list($stdout) = $this->execxLocal('branch');
$this->branch = trim($stdout);
}
return $this->branch;
}
public function didReloadCommitRange() {
$this->localCommitInfo = null;
}
protected function buildBaseCommit($symbolic_commit) {
if ($symbolic_commit !== null) {
try {
$commit = $this->getCanonicalRevisionName(
hgsprintf('ancestor(%s,.)', $symbolic_commit));
} catch (Exception $ex) {
// Try it as a revset instead of a commit id
try {
$commit = $this->getCanonicalRevisionName(
hgsprintf('ancestor(%R,.)', $symbolic_commit));
} catch (Exception $ex) {
throw new ArcanistUsageException(
"Commit '{$symbolic_commit}' is not a valid Mercurial commit ".
"identifier.");
}
}
$this->setBaseCommitExplanation("it is the greatest common ancestor of ".
"the working directory and the commit you specified explicitly.");
return $commit;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getWorkingCopyIdentity()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
"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;
}
// Mercurial 2.1 and up have phases which indicate if something is
// published or not. To find which revs are outgoing, it's much
// faster to check the phase instead of actually checking the server.
if ($this->supportsPhases()) {
list($err, $stdout) = $this->execManualLocal(
'log --branch %s -r %s --style default',
$this->getBranchName(),
'draft()');
} else {
list($err, $stdout) = $this->execManualLocal(
'outgoing --branch %s --style default',
$this->getBranchName());
}
if (!$err) {
$logs = ArcanistMercurialParser::parseMercurialLog($stdout);
} else {
// Mercurial (in some versions?) raises an error when there's nothing
// outgoing.
$logs = array();
}
if (!$logs) {
$this->setBaseCommitExplanation(
"you have no outgoing commits, so arc assumes you intend to submit ".
"uncommitted changes in the working copy.");
return $this->getWorkingCopyRevision();
}
$outgoing_revs = ipull($logs, 'rev');
// This is essentially an implementation of a theoretical `hg merge-base`
// command.
$against = $this->getWorkingCopyRevision();
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) = $this->execxLocal(
'parents --style default --rev %s',
$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;
}
}
if ($against == 'null') {
$this->setBaseCommitExplanation(
"this is a new repository (all changes are outgoing).");
} else {
$this->setBaseCommitExplanation(
"it is the first commit reachable from the working copy state ".
"which is not outgoing.");
}
return $against;
}
public function getLocalCommitInformation() {
if ($this->localCommitInfo === null) {
$base_commit = $this->getBaseCommit();
list($info) = $this->execxLocal(
"log --template %s --rev %s --branch %s --",
"{node}\1{rev}\1{author}\1".
"{date|rfc822date}\1{branch}\1{tag}\1{parents}\1{desc}\2",
hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
$this->getBranchName());
$logs = array_filter(explode("\2", $info));
$last_node = null;
$futures = array();
$commits = array();
foreach ($logs as $log) {
list($node, $rev, $full_author, $date, $branch, $tag,
$parents, $desc) = explode("\1", $log, 9);
$email = new PhutilEmailAddress($full_author);
$author = $email->getDisplayName();
$author_email = $email->getAddress();
// NOTE: If a commit has only one parent, {parents} returns empty.
// If it has two parents, {parents} returns revs and short hashes, not
// full hashes. Try to avoid making calls to "hg parents" because it's
// relatively expensive.
$commit_parents = null;
if (!$parents) {
if ($last_node) {
$commit_parents = array($last_node);
}
}
if (!$commit_parents) {
// We didn't get a cheap hit on previous commit, so do the full-cost
// "hg parents" call. We can run these in parallel, at least.
$futures[$node] = $this->execFutureLocal(
"parents --template='{node}\\n' --rev %s",
$node);
}
$commits[$node] = array(
'author' => $author,
'time' => strtotime($date),
'branch' => $branch,
'tag' => $tag,
'commit' => $node,
'rev' => $node, // TODO: Remove eventually.
'local' => $rev,
'parents' => $commit_parents,
'summary' => head(explode("\n", $desc)),
'message' => $desc,
'authorEmail' => $author_email,
);
$last_node = $node;
}
foreach (Futures($futures)->limit(4) as $node => $future) {
list($parents) = $future->resolvex();
$parents = array_filter(explode("\n", $parents));
$commits[$node]['parents'] = $parents;
}
// Put commits in newest-first order, to be consistent with Git and the
// expected order of "hg log" and "git log" under normal circumstances.
// The order of ancestors() is oldest-first.
$commits = array_reverse($commits);
$this->localCommitInfo = $commits;
}
return $this->localCommitInfo;
}
public function getAllFiles() {
// TODO: Handle paths with newlines.
$future = $this->buildLocalFuture(array('manifest'));
return new LinesOfALargeExecFuture($future);
}
public function getChangedFiles($since_commit) {
list($stdout) = $this->execxLocal(
'status --rev %s',
$since_commit);
return ArcanistMercurialParser::parseMercurialStatus($stdout);
}
public function getBlame($path) {
list($stdout) = $this->execxLocal(
'annotate -u -v -c --rev %s -- %s',
$this->getBaseCommit(),
$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;
}
protected function buildUncommittedStatus() {
list($stdout) = $this->execxLocal('status');
$results = new PhutilArrayWithDefaultValue();
$working_status = ArcanistMercurialParser::parseMercurialStatus($stdout);
foreach ($working_status as $path => $mask) {
if (!($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED)) {
// Mark tracked files as uncommitted.
$mask |= self::FLAG_UNCOMMITTED;
}
$results[$path] |= $mask;
}
return $results->toArray();
}
protected function buildCommitRangeStatus() {
// TODO: Possibly we should use "hg status --rev X --rev ." for this
// instead, but we must run "hg diff" later anyway in most cases, so
// building and caching it shouldn't hurt us.
$diff = $this->getFullMercurialDiff();
if (!$diff) {
return array();
}
$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;
}
return $status_map;
}
protected function didReloadWorkingCopy() {
// Diffs are against ".", so we need to drop the cache if we change the
// working copy.
$this->rawDiffCache = array();
$this->branch = null;
}
private function getDiffOptions() {
$options = array(
'--git',
'-U'.$this->getDiffLinesOfContext(),
);
return implode(' ', $options);
}
public function getRawDiffText($path) {
$options = $this->getDiffOptions();
$range = $this->getBaseCommit();
$raw_diff_cache_key = $options.' '.$range.' '.$path;
if (idx($this->rawDiffCache, $raw_diff_cache_key)) {
return idx($this->rawDiffCache, $raw_diff_cache_key);
}
list($stdout) = $this->execxLocal(
'diff %C --rev %s -- %s',
$options,
$range,
$path);
$this->rawDiffCache[$raw_diff_cache_key] = $stdout;
return $stdout;
}
public function getFullMercurialDiff() {
return $this->getRawDiffText('');
}
public function getOriginalFileData($path) {
return $this->getFileDataAtRevision($path, $this->getBaseCommit());
}
public function getCurrentFileData($path) {
return $this->getFileDataAtRevision(
$path,
$this->getWorkingCopyRevision());
}
public function getBulkOriginalFileData($paths) {
return $this->getBulkFileDataAtRevision($paths, $this->getBaseCommit());
}
public function getBulkCurrentFileData($paths) {
return $this->getBulkFileDataAtRevision(
$paths,
$this->getWorkingCopyRevision());
}
private function getBulkFileDataAtRevision($paths, $revision) {
// Calling 'hg cat' on each file individually is slow (1 second per file
// on a large repo) because mercurial has to decompress and parse the
// entire manifest every time. Do it in one large batch instead.
// hg cat will write the file data to files in a temp directory
$tmpdir = Filesystem::createTemporaryDirectory();
// Mercurial doesn't create the directories for us :(
foreach ($paths as $path) {
$tmppath = $tmpdir.'/'.$path;
Filesystem::createDirectory(dirname($tmppath), 0755, true);
}
list($err, $stdout) = $this->execManualLocal(
'cat --rev %s --output %s -- %C',
$revision,
// %p is the formatter for the repo-relative filepath
$tmpdir.'/%p',
implode(' ', $paths));
$filedata = array();
foreach ($paths as $path) {
$tmppath = $tmpdir.'/'.$path;
if (Filesystem::pathExists($tmppath)) {
$filedata[$path] = Filesystem::readFile($tmppath);
}
}
Filesystem::remove($tmpdir);
return $filedata;
}
private function getFileDataAtRevision($path, $revision) {
list($err, $stdout) = $this->execManualLocal(
'cat --rev %s -- %s',
$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() {
return '.';
}
public function isHistoryDefaultImmutable() {
return true;
}
public function supportsAmend() {
list($err, $stdout) = $this->execManualLocal('help commit');
if ($err) {
return false;
} else {
return (strpos($stdout, "amend") !== false);
}
}
public function supportsRebase() {
if ($this->supportsRebase === null) {
list ($err) = $this->execManualLocal("help rebase");
$this->supportsRebase = $err === 0;
}
return $this->supportsRebase;
}
public function supportsPhases() {
if ($this->supportsPhases === null) {
list ($err) = $this->execManualLocal("help phase");
$this->supportsPhases = $err === 0;
}
return $this->supportsPhases;
}
public function supportsCommitRanges() {
return true;
}
public function supportsLocalCommits() {
return true;
}
public function getAllBranches() {
list($branch_info) = $this->execxLocal('bookmarks');
$matches = null;
preg_match_all(
'/^\s*(\*?)\s*(.+)\s(\S+)$/m',
$branch_info,
$matches,
PREG_SET_ORDER);
$return = array();
foreach ($matches as $match) {
list(, $current, $name) = $match;
$return[] = array(
'current' => (bool)$current,
'name' => rtrim($name),
);
}
return $return;
}
public function hasLocalCommit($commit) {
try {
$this->getCanonicalRevisionName($commit);
return true;
} catch (Exception $ex) {
return false;
}
}
public function getCommitMessage($commit) {
list($message) = $this->execxLocal(
'log --template={desc} --rev %s',
$commit);
return $message;
}
public function getAllLocalChanges() {
$diff = $this->getFullMercurialDiff();
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) {
$err = phutil_passthru(
'(cd %s && HGPLAIN=1 hg merge --rev %s && hg commit -m %s)',
$this->getPath(),
$branch,
$message);
} else {
$err = phutil_passthru(
'(cd %s && HGPLAIN=1 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 getCommitMessageLog() {
$base_commit = $this->getBaseCommit();
list($stdout) = $this->execxLocal(
"log --template '{node}\\2{desc}\\1' --rev %s --branch %s --",
hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
$this->getBranchName());
$map = array();
$logs = explode("\1", trim($stdout));
foreach (array_filter($logs) as $log) {
list($node, $desc) = explode("\2", $log);
$map[$node] = $desc;
}
return array_reverse($map);
}
public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query) {
$messages = $this->getCommitMessageLog();
$parser = new ArcanistDiffParser();
// First, try to find revisions by explicit revision IDs in commit messages.
$reason_map = array();
$revision_ids = array();
foreach ($messages as $node_id => $message) {
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message);
if ($object->getRevisionID()) {
$revision_ids[] = $object->getRevisionID();
$reason_map[$object->getRevisionID()] = $node_id;
}
}
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'] =
"Commit message for '{$hash}' has explicit 'Differential Revision'.";
}
return $results;
}
// Try to find revisions by hash.
$hashes = array();
foreach ($this->getLocalCommitInformation() as $commit) {
$hashes[] = array('hgcm', $commit['commit']);
}
if ($hashes) {
// NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working
// copy with dirty changes, there may be no local commits.
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'commitHashes' => $hashes,
));
foreach ($results as $key => $hash) {
$results[$key]['why'] =
"A mercurial commit hash in the commit range is already attached ".
"to the Differential revision.";
}
return $results;
}
return array();
}
public function updateWorkingCopy() {
$this->execxLocal('up');
$this->reloadWorkingCopy();
}
private function getMercurialConfig($key, $default = null) {
list($stdout) = $this->execxLocal('showconfig %s', $key);
if ($stdout == '') {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
$full_author = $this->getMercurialConfig('ui.username');
$email = new PhutilEmailAddress($full_author);
$author = $email->getDisplayName();
return $author;
}
public function addToCommit(array $paths) {
$this->execxLocal(
'addremove -- %Ls',
$paths);
$this->reloadWorkingCopy();
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit -l %s',
$tmp_file);
$this->reloadWorkingCopy();
}
- public function amendCommit($message) {
+ public function amendCommit($message = null) {
+ if ($message === null) {
+ $message = $this->getCommitMessage('.');
+ }
+
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit --amend -l %s',
$tmp_file);
$this->reloadWorkingCopy();
}
public function getCommitSummary($commit) {
if ($commit == 'null') {
return '(The Empty Void)';
}
list($summary) = $this->execxLocal(
'log --template {desc} --limit 1 --rev %s',
$commit);
$summary = head(explode("\n", $summary));
return trim($summary);
}
public function resolveBaseCommitRule($rule, $source) {
list($type, $name) = explode(':', $rule, 2);
// NOTE: This function MUST return node hashes or symbolic commits (like
// branch names or the word "tip"), not revsets. This includes ".^" and
// similar, which a revset, not a symbolic commit identifier. If you return
// a revset it will be escaped later and looked up literally.
switch ($type) {
case 'hg':
$matches = null;
if (preg_match('/^gca\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'log --template={node} --rev %s',
sprintf('ancestor(., %s)', $matches[1]));
if (!$err) {
$this->setBaseCommitExplanation(
"it is the greatest common ancestor of '{$matches[1]}' and ., as".
"specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
return trim($merge_base);
}
} else {
list($err, $commit) = $this->execManualLocal(
'log --template {node} --rev %s',
hgsprintf('%s', $name));
if ($err) {
list($err, $commit) = $this->execManualLocal(
'log --template {node} --rev %s',
$name);
}
if (!$err) {
$this->setBaseCommitExplanation(
"it is specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
return trim($commit);
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
"you specified '{$rule}' in your {$source} 'base' ".
"configuration.");
return 'null';
case 'outgoing':
list($err, $outgoing_base) = $this->execManualLocal(
'log --template={node} --rev %s',
'limit(reverse(ancestors(.) - outgoing()), 1)');
if (!$err) {
$this->setBaseCommitExplanation(
"it is the first ancestor of the working copy that is not ".
"outgoing, and it matched the rule {$rule} in your {$source} ".
"'base' configuration.");
return trim($outgoing_base);
}
case 'amended':
$text = $this->getCommitMessage('.');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
"'.' has been amended with 'Differential Revision:', ".
"as specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
// NOTE: This should be safe because Mercurial doesn't support
// amend until 2.2.
return $this->getCanonicalRevisionName('.^');
}
break;
case 'bookmark':
$revset =
'limit('.
' sort('.
' (ancestors(.) and bookmark() - .) or'.
' (ancestors(.) - outgoing()), '.
' -rev),'.
'1)';
list($err, $bookmark_base) = $this->execManualLocal(
'log --template={node} --rev %s',
$revset);
if (!$err) {
$this->setBaseCommitExplanation(
"it is the first ancestor of . that either has a bookmark, or ".
"is already in the remote and it matched the rule {$rule} in ".
"your {$source} 'base' configuration");
return trim($bookmark_base);
}
break;
case 'this':
$this->setBaseCommitExplanation(
"you specified '{$rule}' in your {$source} 'base' ".
"configuration.");
return $this->getCanonicalRevisionName('.^');
default:
if (preg_match('/^nodiff\((.+)\)$/', $name, $matches)) {
list($results) = $this->execxLocal(
'log --template %s --rev %s',
'{node}\1{desc}\2',
sprintf('ancestor(.,%s)::.^', $matches[1]));
$results = array_reverse(explode("\2", trim($results)));
foreach ($results as $result) {
if (empty($result)) {
continue;
}
list($node, $desc) = explode("\1", $result, 2);
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$desc);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
"it is the first ancestor of . that has a diff ".
"and is the gca or a descendant of the gca with ".
"'{$matches[1]}', specified by '{$rule}' in your ".
"{$source} 'base' configuration.");
return $node;
}
}
}
break;
}
break;
default:
return null;
}
return null;
}
public function isHgSubversionRepo() {
return file_exists($this->getPath('.hg/svn'));
}
public function getSubversionInfo() {
$info = array();
$base_path = null;
$revision = null;
list($err, $raw_info) = $this->execManualLocal('svn info');
if (!$err) {
foreach (explode("\n", trim($raw_info)) as $line) {
list($key, $value) = explode(': ', $line, 2);
switch ($key) {
case 'URL':
$info['base_path'] = $value;
$base_path = $value;
break;
case 'Repository UUID':
$info['uuid'] = $value;
break;
case 'Revision':
$revision = $value;
break;
default:
break;
}
}
if ($base_path && $revision) {
$info['base_revision'] = $base_path.'@'.$revision;
}
}
return $info;
}
public function getActiveBookmark() {
$bookmarks = $this->getBookmarks();
foreach ($bookmarks as $bookmark) {
if ($bookmark['is_active']) {
return $bookmark['name'];
}
}
return null;
}
public function isBookmark($name) {
$bookmarks = $this->getBookmarks();
foreach ($bookmarks as $bookmark) {
if ($bookmark['name'] === $name) {
return true;
}
}
return false;
}
public function isBranch($name) {
$branches = $this->getBranches();
foreach ($branches as $branch) {
if ($branch['name'] === $name) {
return true;
}
}
return false;
}
public function getBranches() {
$branches = array();
list($raw_output) = $this->execxLocal('branches');
$raw_output = trim($raw_output);
foreach (explode("\n", $raw_output) as $line) {
// example line: default 0:a5ead76cdf85 (inactive)
list($name, $rev_line) = $this->splitBranchOrBookmarkLine($line);
// strip off the '(inactive)' bit if it exists
$rev_parts = explode(' ', $rev_line);
$revision = $rev_parts[0];
$branches[] = array(
'name' => $name,
'revision' => $revision);
}
return $branches;
}
public function getBookmarks() {
$bookmarks = array();
list($raw_output) = $this->execxLocal('bookmarks');
$raw_output = trim($raw_output);
if ($raw_output !== 'no bookmarks set') {
foreach (explode("\n", $raw_output) as $line) {
// example line: * mybook 2:6b274d49be97
list($name, $revision) = $this->splitBranchOrBookmarkLine($line);
$is_active = false;
if ('*' === $name[0]) {
$is_active = true;
$name = substr($name, 2);
}
$bookmarks[] = array(
'is_active' => $is_active,
'name' => $name,
'revision' => $revision);
}
}
return $bookmarks;
}
private function splitBranchOrBookmarkLine($line) {
// branches and bookmarks are printed in the format:
// default 0:a5ead76cdf85 (inactive)
// * mybook 2:6b274d49be97
// this code divides the name half from the revision half
// it does not parse the * and (inactive) bits
$colon_index = strrpos($line, ':');
$before_colon = substr($line, 0, $colon_index);
$start_rev_index = strrpos($before_colon, ' ');
$name = substr($line, 0, $start_rev_index);
$rev = substr($line, $start_rev_index);
return array(trim($name), trim($rev));
}
}
diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php
index 5e42918b..b45184bb 100644
--- a/src/repository/api/ArcanistRepositoryAPI.php
+++ b/src/repository/api/ArcanistRepositoryAPI.php
@@ -1,663 +1,663 @@
<?php
/**
* Interfaces with the VCS in the working copy.
*
* @task status Path Status
* @group workingcopy
*/
abstract class ArcanistRepositoryAPI {
const FLAG_MODIFIED = 1;
const FLAG_ADDED = 2;
const FLAG_DELETED = 4;
const FLAG_UNTRACKED = 8;
const FLAG_CONFLICT = 16;
const FLAG_MISSING = 32;
const FLAG_UNSTAGED = 64;
const FLAG_UNCOMMITTED = 128;
const FLAG_EXTERNALS = 256;
// Occurs in SVN when you replace a file with a directory without telling
// SVN about it.
const FLAG_OBSTRUCTED = 512;
// Occurs in SVN when an update was interrupted or failed, e.g. you ^C'd it.
const FLAG_INCOMPLETE = 1024;
protected $path;
protected $diffLinesOfContext = 0x7FFF;
private $baseCommitExplanation = '???';
private $workingCopyIdentity;
private $baseCommitArgumentRules;
private $uncommittedStatusCache;
private $commitRangeStatusCache;
private $symbolicBaseCommit;
private $resolvedBaseCommit;
abstract public function getSourceControlSystemName();
public function getDiffLinesOfContext() {
return $this->diffLinesOfContext;
}
public function setDiffLinesOfContext($lines) {
$this->diffLinesOfContext = $lines;
return $this;
}
public function getWorkingCopyIdentity() {
return $this->workingCopyIdentity;
}
public static function newAPIFromWorkingCopyIdentity(
ArcanistWorkingCopyIdentity $working_copy) {
$root = $working_copy->getProjectRoot();
if (!$root) {
throw new ArcanistUsageException(
"There is no readable '.arcconfig' file in the working directory or ".
"any parent directory. Create an '.arcconfig' file to configure arc.");
}
if (Filesystem::pathExists($root.'/.hg')) {
$api = new ArcanistMercurialAPI($root);
$api->workingCopyIdentity = $working_copy;
return $api;
}
$git_root = self::discoverGitBaseDirectory($root);
if ($git_root) {
if (!Filesystem::pathsAreEquivalent($root, $git_root)) {
throw new ArcanistUsageException(
"'.arcconfig' file is located at '{$root}', but working copy root ".
"is '{$git_root}'. Move '.arcconfig' file to the working copy root.");
}
$api = new ArcanistGitAPI($root);
$api->workingCopyIdentity = $working_copy;
return $api;
}
// check if we're in an svn working copy
foreach (Filesystem::walkToRoot($root) as $dir) {
if (Filesystem::pathExists($dir . '/.svn')) {
$api = new ArcanistSubversionAPI($root);
$api->workingCopyIdentity = $working_copy;
return $api;
}
}
throw new ArcanistUsageException(
"The current working directory is not part of a working copy for a ".
"supported version control system (svn, git or mercurial).");
}
public function __construct($path) {
$this->path = $path;
}
public function getPath($to_file = null) {
if ($to_file !== null) {
return $this->path.DIRECTORY_SEPARATOR.
ltrim($to_file, DIRECTORY_SEPARATOR);
} else {
return $this->path.DIRECTORY_SEPARATOR;
}
}
/* -( Path Status )-------------------------------------------------------- */
abstract protected function buildUncommittedStatus();
abstract protected function buildCommitRangeStatus();
/**
* Get a list of uncommitted paths in the working copy that have been changed
* or are affected by other status effects, like conflicts or untracked
* files.
*
* Convenience methods @{method:getUntrackedChanges},
* @{method:getUnstagedChanges}, @{method:getUncommittedChanges},
* @{method:getMergeConflicts}, and @{method:getIncompleteChanges} allow
* simpler selection of paths in a specific state.
*
* This method returns a map of paths to bitmasks with status, using
* `FLAG_` constants. For example:
*
* array(
* 'some/uncommitted/file.txt' => ArcanistRepositoryAPI::FLAG_UNSTAGED,
* );
*
* A file may be in several states. Not all states are possible with all
* version control systems.
*
* @return map<string, bitmask> Map of paths, see above.
* @task status
*/
final public function getUncommittedStatus() {
if ($this->uncommittedStatusCache === null) {
$status = $this->buildUncommittedStatus();
ksort($status);
$this->uncommittedStatusCache = $status;
}
return $this->uncommittedStatusCache;
}
/**
* @task status
*/
final public function getUntrackedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNTRACKED);
}
/**
* @task status
*/
final public function getUnstagedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNSTAGED);
}
/**
* @task status
*/
final public function getUncommittedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNCOMMITTED);
}
/**
* @task status
*/
final public function getMergeConflicts() {
return $this->getUncommittedPathsWithMask(self::FLAG_CONFLICT);
}
/**
* @task status
*/
final public function getIncompleteChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_INCOMPLETE);
}
/**
* @task status
*/
private function getUncommittedPathsWithMask($mask) {
$match = array();
foreach ($this->getUncommittedStatus() as $path => $flags) {
if ($flags & $mask) {
$match[] = $path;
}
}
return $match;
}
/**
* Get a list of paths affected by the commits in the current commit range.
*
* See @{method:getUncommittedStatus} for a description of the return value.
*
* @return map<string, bitmask> Map from paths to status.
* @task status
*/
final public function getCommitRangeStatus() {
if ($this->commitRangeStatusCache === null) {
$status = $this->buildCommitRangeStatus();
ksort($status);
$this->commitRangeStatusCache = $status;
}
return $this->commitRangeStatusCache;
}
/**
* Get a list of paths affected by commits in the current commit range, or
* uncommitted changes in the working copy. See @{method:getUncommittedStatus}
* or @{method:getCommitRangeStatus} to retreive smaller parts of the status.
*
* See @{method:getUncommittedStatus} for a description of the return value.
*
* @return map<string, bitmask> Map from paths to status.
* @task status
*/
final public function getWorkingCopyStatus() {
$range_status = $this->getCommitRangeStatus();
$uncommitted_status = $this->getUncommittedStatus();
$result = new PhutilArrayWithDefaultValue($range_status);
foreach ($uncommitted_status as $path => $mask) {
$result[$path] |= $mask;
}
$result = $result->toArray();
ksort($result);
return $result;
}
/**
* Drops caches after changes to the working copy. By default, some queries
* against the working copy are cached. They
*
* @return this
* @task status
*/
final public function reloadWorkingCopy() {
$this->uncommittedStatusCache = null;
$this->commitRangeStatusCache = null;
$this->didReloadWorkingCopy();
$this->reloadCommitRange();
return $this;
}
/**
* Hook for implementations to dirty working copy caches after the working
* copy has been updated.
*
* @return this
* @task status
*/
protected function didReloadWorkingCopy() {
return;
}
private static function discoverGitBaseDirectory($root) {
try {
// NOTE: This awkward construction is to make sure things work on Windows.
$future = new ExecFuture('git rev-parse --show-cdup');
$future->setCWD($root);
list($stdout) = $future->resolvex();
return Filesystem::resolvePath(rtrim($stdout, "\n"), $root);
} catch (CommandException $ex) {
// This might be because the $root isn't a Git working copy, or the user
// might not have Git installed at all so the `git` command fails. Assume
// that users trying to work with git working copies will have a working
// `git` binary.
return null;
}
}
/**
* Fetches the original file data for each path provided.
*
* @return map<string, string> Map from path to file data.
*/
public function getBulkOriginalFileData($paths) {
$filedata = array();
foreach ($paths as $path) {
$filedata[$path] = $this->getOriginalFileData($path);
}
return $filedata;
}
/**
* Fetches the current file data for each path provided.
*
* @return map<string, string> Map from path to file data.
*/
public function getBulkCurrentFileData($paths) {
$filedata = array();
foreach ($paths as $path) {
$filedata[$path] = $this->getCurrentFileData($path);
}
return $filedata;
}
/**
* @return Traversable
*/
abstract public function getAllFiles();
abstract public function getBlame($path);
abstract public function getRawDiffText($path);
abstract public function getOriginalFileData($path);
abstract public function getCurrentFileData($path);
abstract public function getLocalCommitInformation();
abstract public function getSourceControlBaseRevision();
abstract public function getCanonicalRevisionName($string);
abstract public function getBranchName();
abstract public function getSourceControlPath();
abstract public function isHistoryDefaultImmutable();
abstract public function supportsAmend();
abstract public function getWorkingCopyRevision();
abstract public function updateWorkingCopy();
abstract public function getMetadataPath();
abstract public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query);
public function getUnderlyingWorkingCopyRevision() {
return $this->getWorkingCopyRevision();
}
public function getChangedFiles($since_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAuthor() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function addToCommit(array $paths) {
throw new ArcanistCapabilityNotSupportedException($this);
}
abstract public function supportsLocalCommits();
public function doCommit($message) {
throw new ArcanistCapabilityNotSupportedException($this);
}
- public function amendCommit($message) {
+ public function amendCommit($message = null) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAllBranches() {
// TODO: Implement for Mercurial/SVN and make abstract.
return array();
}
public function hasLocalCommit($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getCommitMessage($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getCommitSummary($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAllLocalChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
abstract public function supportsLocalBranchMerge();
public function performLocalBranchMerge($branch, $message) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getFinalizedRevisionMessage() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function execxLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)->resolvex();
}
public function execManualLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)->resolve();
}
public function execFutureLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args);
}
abstract protected function buildLocalFuture(array $argv);
public function canStashChanges() {
return false;
}
public function stashChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function unstashChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
/* -( Scratch Files )------------------------------------------------------ */
/**
* Try to read a scratch file, if it exists and is readable.
*
* @param string Scratch file name.
* @return mixed String for file contents, or false for failure.
* @task scratch
*/
public function readScratchFile($path) {
$full_path = $this->getScratchFilePath($path);
if (!$full_path) {
return false;
}
if (!Filesystem::pathExists($full_path)) {
return false;
}
try {
$result = Filesystem::readFile($full_path);
} catch (FilesystemException $ex) {
return false;
}
return $result;
}
/**
* Try to write a scratch file, if there's somewhere to put it and we can
* write there.
*
* @param string Scratch file name to write.
* @param string Data to write.
* @return bool True on success, false on failure.
* @task scratch
*/
public function writeScratchFile($path, $data) {
$dir = $this->getScratchFilePath('');
if (!$dir) {
return false;
}
if (!Filesystem::pathExists($dir)) {
try {
Filesystem::createDirectory($dir);
} catch (Exception $ex) {
return false;
}
}
try {
Filesystem::writeFile($this->getScratchFilePath($path), $data);
} catch (FilesystemException $ex) {
return false;
}
return true;
}
/**
* Try to remove a scratch file.
*
* @param string Scratch file name to remove.
* @return bool True if the file was removed successfully.
* @task scratch
*/
public function removeScratchFile($path) {
$full_path = $this->getScratchFilePath($path);
if (!$full_path) {
return false;
}
try {
Filesystem::remove($full_path);
} catch (FilesystemException $ex) {
return false;
}
return true;
}
/**
* Get a human-readable description of the scratch file location.
*
* @param string Scratch file name.
* @return mixed String, or false on failure.
* @task scratch
*/
public function getReadableScratchFilePath($path) {
$full_path = $this->getScratchFilePath($path);
if ($full_path) {
return Filesystem::readablePath(
$full_path,
$this->getPath());
} else {
return false;
}
}
/**
* Get the path to a scratch file, if possible.
*
* @param string Scratch file name.
* @return mixed File path, or false on failure.
* @task scratch
*/
public function getScratchFilePath($path) {
$new_scratch_path = Filesystem::resolvePath(
'arc',
$this->getMetadataPath());
static $checked = false;
if (!$checked) {
$checked = true;
$old_scratch_path = $this->getPath('.arc');
// we only want to do the migration once
// unfortunately, people have checked in .arc directories which
// means that the old one may get recreated after we delete it
if (Filesystem::pathExists($old_scratch_path) &&
!Filesystem::pathExists($new_scratch_path)) {
Filesystem::createDirectory($new_scratch_path);
$existing_files = Filesystem::listDirectory($old_scratch_path, true);
foreach ($existing_files as $file) {
$new_path = Filesystem::resolvePath($file, $new_scratch_path);
$old_path = Filesystem::resolvePath($file, $old_scratch_path);
Filesystem::writeFile(
$new_path,
Filesystem::readFile($old_path));
}
Filesystem::remove($old_scratch_path);
}
}
return Filesystem::resolvePath($path, $new_scratch_path);
}
/* -( Base Commits )------------------------------------------------------- */
abstract public function supportsCommitRanges();
final public function setBaseCommit($symbolic_commit) {
if (!$this->supportsCommitRanges()) {
throw new ArcanistCapabilityNotSupportedException($this);
}
$this->symbolicBaseCommit = $symbolic_commit;
$this->reloadCommitRange();
return $this;
}
final public function getBaseCommit() {
if (!$this->supportsCommitRanges()) {
throw new ArcanistCapabilityNotSupportedException($this);
}
if ($this->resolvedBaseCommit === null) {
$commit = $this->buildBaseCommit($this->symbolicBaseCommit);
$this->resolvedBaseCommit = $commit;
}
return $this->resolvedBaseCommit;
}
final public function reloadCommitRange() {
$this->resolvedBaseCommit = null;
$this->baseCommitExplanation = null;
$this->didReloadCommitRange();
return $this;
}
protected function didReloadCommitRange() {
return;
}
protected function buildBaseCommit($symbolic_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getBaseCommitExplanation() {
return $this->baseCommitExplanation;
}
public function setBaseCommitExplanation($explanation) {
$this->baseCommitExplanation = $explanation;
return $this;
}
public function resolveBaseCommitRule($rule, $source) {
return null;
}
public function setBaseCommitArgumentRules($base_commit_argument_rules) {
$this->baseCommitArgumentRules = $base_commit_argument_rules;
return $this;
}
public function getBaseCommitArgumentRules() {
return $this->baseCommitArgumentRules;
}
public function resolveBaseCommit() {
$working_copy = $this->getWorkingCopyIdentity();
$global_config = ArcanistBaseWorkflow::readGlobalArcConfig();
$system_config = ArcanistBaseWorkflow::readSystemArcConfig();
$parser = new ArcanistBaseCommitParser($this);
$commit = $parser->resolveBaseCommit(
array(
'args' => $this->getBaseCommitArgumentRules(),
'local' => $working_copy->getLocalConfig('base', ''),
'project' => $working_copy->getConfig('base', ''),
'global' => idx($global_config, 'base', ''),
'system' => idx($system_config, 'base', ''),
));
return $commit;
}
}
diff --git a/src/workflow/ArcanistLintWorkflow.php b/src/workflow/ArcanistLintWorkflow.php
index 054e409e..bf2c84e2 100644
--- a/src/workflow/ArcanistLintWorkflow.php
+++ b/src/workflow/ArcanistLintWorkflow.php
@@ -1,591 +1,596 @@
<?php
/**
* Runs lint rules on changes.
*
* @group workflow
*/
final class ArcanistLintWorkflow extends ArcanistBaseWorkflow {
const RESULT_OKAY = 0;
const RESULT_WARNINGS = 1;
const RESULT_ERRORS = 2;
const RESULT_SKIP = 3;
const RESULT_POSTPONED = 4;
const DEFAULT_SEVERITY = ArcanistLintSeverity::SEVERITY_ADVICE;
private $unresolvedMessages;
private $shouldLintAll;
private $shouldAmendChanges = false;
private $shouldAmendWithoutPrompt = false;
private $shouldAmendAutofixesWithoutPrompt = false;
private $engine;
private $postponedLinters;
public function getWorkflowName() {
return 'lint';
}
public function setShouldAmendChanges($should_amend) {
$this->shouldAmendChanges = $should_amend;
return $this;
}
public function setShouldAmendWithoutPrompt($should_amend) {
$this->shouldAmendWithoutPrompt = $should_amend;
return $this;
}
public function setShouldAmendAutofixesWithoutPrompt($should_amend) {
$this->shouldAmendAutofixesWithoutPrompt = $should_amend;
return $this;
}
public function getCommandSynopses() {
return phutil_console_format(<<<EOTEXT
**lint** [__options__] [__paths__]
**lint** [__options__] --rev [__rev__]
EOTEXT
);
}
public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT
Supports: git, svn, hg
Run static analysis on changes to check for mistakes. If no files
are specified, lint will be run on all files which have been modified.
EOTEXT
);
}
public function getArguments() {
return array(
'lintall' => array(
'help' =>
"Show all lint warnings, not just those on changed lines."
),
'rev' => array(
'param' => 'revision',
'help' => "Lint changes since a specific revision.",
'supports' => array(
'git',
'hg',
),
'nosupport' => array(
'svn' => "Lint does not currently support --rev in SVN.",
),
),
'output' => array(
'param' => 'format',
'help' =>
"With 'summary', show lint warnings in a more compact format. ".
"With 'json', show lint warnings in machine-readable JSON format. ".
"With 'none', show no lint warnings. ".
"With 'compiler', show lint warnings in suitable for your editor."
),
'only-new' => array(
'param' => 'bool',
'supports' => array('git', 'hg'), // TODO: svn
'help' => 'Display only messages not present in the original code.',
),
'engine' => array(
'param' => 'classname',
'help' =>
"Override configured lint engine for this project."
),
'apply-patches' => array(
'help' =>
'Apply patches suggested by lint to the working copy without '.
'prompting.',
'conflicts' => array(
'never-apply-patches' => true,
),
),
'never-apply-patches' => array(
'help' => 'Never apply patches suggested by lint.',
'conflicts' => array(
'apply-patches' => true,
),
),
'amend-all' => array(
'help' =>
'When linting git repositories, amend HEAD with all patches '.
'suggested by lint without prompting.',
),
'amend-autofixes' => array(
'help' =>
'When linting git repositories, amend HEAD with autofix '.
'patches suggested by lint without prompting.',
),
'severity' => array(
'param' => 'string',
'help' =>
"Set minimum message severity. One of: '".
implode(
"', '",
array_keys(ArcanistLintSeverity::getLintSeverities())).
"'. Defaults to '".self::DEFAULT_SEVERITY."'.",
),
'cache' => array(
'param' => 'bool',
'help' => "0 to disable cache, 1 to enable (default).",
),
'*' => 'paths',
);
}
public function requiresAuthentication() {
return (bool)$this->getArgument('only-new');
}
public function requiresWorkingCopy() {
return true;
}
public function requiresRepositoryAPI() {
return true;
}
private function getCacheKey() {
return implode("\n", array(
get_class($this->engine),
$this->getArgument('severity', self::DEFAULT_SEVERITY),
$this->shouldLintAll,
));
}
public function run() {
$working_copy = $this->getWorkingCopy();
$engine = $this->getArgument('engine');
if (!$engine) {
$engine = $working_copy->getConfigFromAnySource('lint.engine');
if (!$engine) {
throw new ArcanistNoEngineException(
"No lint engine configured for this project. Edit .arcconfig to ".
"specify a lint engine.");
}
}
$rev = $this->getArgument('rev');
$paths = $this->getArgument('paths');
$use_cache = $this->getArgument('cache', true);
if ($rev && $paths) {
throw new ArcanistUsageException("Specify either --rev or paths.");
}
$this->shouldLintAll = $this->getArgument('lintall');
if ($paths) {
// NOTE: When the user specifies paths, we imply --lintall and show all
// warnings for the paths in question. This is easier to deal with for
// us and less confusing for users.
$this->shouldLintAll = true;
}
$paths = $this->selectPathsForWorkflow($paths, $rev);
if (!class_exists($engine) ||
!is_subclass_of($engine, 'ArcanistLintEngine')) {
throw new ArcanistUsageException(
"Configured lint engine '{$engine}' is not a subclass of ".
"'ArcanistLintEngine'.");
}
$engine = newv($engine, array());
$this->engine = $engine;
$engine->setWorkingCopy($working_copy);
$engine->setMinimumSeverity(
$this->getArgument('severity', self::DEFAULT_SEVERITY));
$file_hashes = array();
if ($use_cache) {
$engine->setRepositoryVersion($this->getRepositoryVersion());
$cache = $this->readScratchJSONFile('lint-cache.json');
$cache = idx($cache, $this->getCacheKey(), array());
$cached = array();
foreach ($paths as $path) {
$abs_path = $engine->getFilePathOnDisk($path);
if (!Filesystem::pathExists($abs_path)) {
continue;
}
$file_hashes[$abs_path] = md5_file($abs_path);
if (!isset($cache[$path])) {
continue;
}
$messages = idx($cache[$path], $file_hashes[$abs_path]);
if ($messages !== null) {
$cached[$path] = $messages;
}
}
$engine->setCachedResults($cached);
}
// Propagate information about which lines changed to the lint engine.
// This is used so that the lint engine can drop warning messages
// concerning lines that weren't in the change.
$engine->setPaths($paths);
if (!$this->shouldLintAll) {
foreach ($paths as $path) {
// Note that getChangedLines() returns null to indicate that a file
// is binary or a directory (i.e., changed lines are not relevant).
$engine->setPathChangedLines(
$path,
$this->getChangedLines($path, 'new'));
}
}
// Enable possible async linting only for 'arc diff' not 'arc lint'
if ($this->getParentWorkflow()) {
$engine->setEnableAsyncLint(true);
} else {
$engine->setEnableAsyncLint(false);
}
if ($this->getArgument('only-new')) {
$conduit = $this->getConduit();
$api = $this->getRepositoryAPI();
if ($rev) {
$api->setBaseCommit($rev);
}
$svn_root = id(new PhutilURI($api->getSourceControlPath()))->getPath();
$all_paths = array();
foreach ($paths as $path) {
$path = str_replace(DIRECTORY_SEPARATOR, '/', $path);
$full_paths = array($path);
$change = $this->getChange($path);
$type = $change->getType();
if (ArcanistDiffChangeType::isOldLocationChangeType($type)) {
$full_paths = $change->getAwayPaths();
} else if (ArcanistDiffChangeType::isNewLocationChangeType($type)) {
continue;
} else if (ArcanistDiffChangeType::isDeleteChangeType($type)) {
continue;
}
foreach ($full_paths as $full_path) {
$all_paths[$svn_root.'/'.$full_path] = $path;
}
}
$lint_future = $conduit->callMethod('diffusion.getlintmessages', array(
'arcanistProject' => $this->getWorkingCopy()->getProjectID(),
'branch' => '', // TODO: Tracking branch.
'commit' => $api->getBaseCommit(),
'files' => array_keys($all_paths),
));
}
$failed = null;
try {
$engine->run();
} catch (Exception $ex) {
$failed = $ex;
}
$results = $engine->getResults();
if ($this->getArgument('only-new')) {
$total = 0;
foreach ($results as $result) {
$total += count($result->getMessages());
}
// Don't wait for response with default value of --only-new.
$timeout = null;
if ($this->getArgument('only-new') === null || !$total) {
$timeout = 0;
}
$raw_messages = $this->resolveCall($lint_future, $timeout);
if ($raw_messages && $total) {
$old_messages = array();
$line_maps = array();
foreach ($raw_messages as $message) {
$path = $all_paths[$message['path']];
$line = $message['line'];
$code = $message['code'];
if (!isset($line_maps[$path])) {
$line_maps[$path] = $this->getChange($path)->buildLineMap();
}
$new_lines = idx($line_maps[$path], $line);
if (!$new_lines) { // Unmodified lines after last hunk.
$last_old = ($line_maps[$path] ? last_key($line_maps[$path]) : 0);
$news = array_filter($line_maps[$path]);
$last_new = ($news ? last(end($news)) : 0);
$new_lines = array($line + $last_new - $last_old);
}
$error = array($code => array(true));
foreach ($new_lines as $new) {
if (isset($old_messages[$path][$new])) {
$old_messages[$path][$new][$code][] = true;
break;
}
$old_messages[$path][$new] = &$error;
}
unset($error);
}
foreach ($results as $result) {
foreach ($result->getMessages() as $message) {
$path = str_replace(DIRECTORY_SEPARATOR, '/', $message->getPath());
$line = $message->getLine();
$code = $message->getCode();
if (!empty($old_messages[$path][$line][$code])) {
$message->setObsolete(true);
array_pop($old_messages[$path][$line][$code]);
}
}
$result->sortAndFilterMessages();
}
}
}
// It'd be nice to just return a single result from the run method above
// which contains both the lint messages and the postponed linters.
// However, to maintain compatibility with existing lint subclasses, use
// a separate method call to grab the postponed linters.
$this->postponedLinters = $engine->getPostponedLinters();
if ($this->getArgument('never-apply-patches')) {
$apply_patches = false;
} else {
$apply_patches = true;
}
if ($this->getArgument('apply-patches')) {
$prompt_patches = false;
} else {
$prompt_patches = true;
}
if ($this->getArgument('amend-all')) {
$this->shouldAmendChanges = true;
$this->shouldAmendWithoutPrompt = true;
}
if ($this->getArgument('amend-autofixes')) {
$prompt_autofix_patches = false;
$this->shouldAmendChanges = true;
$this->shouldAmendAutofixesWithoutPrompt = true;
} else {
$prompt_autofix_patches = true;
}
+ $repository_api = $this->getRepositoryAPI();
+ if ($this->shouldAmendChanges) {
+ $this->shouldAmendChanges = $repository_api->supportsAmend() &&
+ !$this->isHistoryImmutable();
+ }
+
$wrote_to_disk = false;
switch ($this->getArgument('output')) {
case 'json':
$renderer = new ArcanistLintJSONRenderer();
$prompt_patches = false;
$apply_patches = $this->getArgument('apply-patches');
break;
case 'summary':
$renderer = new ArcanistLintSummaryRenderer();
break;
case 'none':
$prompt_patches = false;
$apply_patches = $this->getArgument('apply-patches');
$renderer = new ArcanistLintNoneRenderer();
break;
case 'compiler':
$renderer = new ArcanistLintLikeCompilerRenderer();
$prompt_patches = false;
$apply_patches = $this->getArgument('apply-patches');
break;
default:
$renderer = new ArcanistLintConsoleRenderer();
$renderer->setShowAutofixPatches($prompt_autofix_patches);
break;
}
$all_autofix = true;
$console = PhutilConsole::getConsole();
foreach ($results as $result) {
$result_all_autofix = $result->isAllAutofix();
if (!$result->getMessages() && !$result_all_autofix) {
continue;
}
if (!$result_all_autofix) {
$all_autofix = false;
}
$lint_result = $renderer->renderLintResult($result);
if ($lint_result) {
$console->writeOut('%s', $lint_result);
}
if ($apply_patches && $result->isPatchable()) {
$patcher = ArcanistLintPatcher::newFromArcanistLintResult($result);
$old_file = $result->getFilePathOnDisk();
if ($prompt_patches &&
!($result_all_autofix && !$prompt_autofix_patches)) {
if (!Filesystem::pathExists($old_file)) {
$old_file = '/dev/null';
}
$new_file = new TempFile();
$new = $patcher->getModifiedFileContent();
Filesystem::writeFile($new_file, $new);
// TODO: Improve the behavior here, make it more like
// difference_render().
list(, $stdout, $stderr) =
exec_manual("diff -u %s %s", $old_file, $new_file);
$console->writeOut('%s', $stdout);
$console->writeErr('%s', $stderr);
$prompt = phutil_console_format(
"Apply this patch to __%s__?",
$result->getPath());
if (!$console->confirm($prompt, $default_no = false)) {
continue;
}
}
$patcher->writePatchToDisk();
$wrote_to_disk = true;
$file_hashes[$old_file] = md5_file($old_file);
}
}
- $repository_api = $this->getRepositoryAPI();
- if ($wrote_to_disk &&
- ($repository_api instanceof ArcanistGitAPI) &&
- $this->shouldAmendChanges) {
-
+ if ($wrote_to_disk && $this->shouldAmendChanges) {
if ($this->shouldAmendWithoutPrompt ||
($this->shouldAmendAutofixesWithoutPrompt && $all_autofix)) {
$console->writeOut(
"<bg:yellow>** LINT NOTICE **</bg> Automatically amending HEAD ".
"with lint patches.\n");
$amend = true;
} else {
$amend = $console->confirm("Amend HEAD with lint patches?");
}
if ($amend) {
- execx(
- '(cd %s; git commit -a --amend -C HEAD)',
- $repository_api->getPath());
+ if ($repository_api instanceof ArcanistGitAPI) {
+ // Add the changes to the index before amending
+ $repository_api->execxLocal('add -A');
+ }
+
+ $repository_api->amendCommit();
} else {
throw new ArcanistUsageException(
"Sort out the lint changes that were applied to the working ".
"copy and relint.");
}
}
if ($this->getArgument('output') == 'json') {
// NOTE: Required by save_lint.php in Phabricator.
return 0;
}
if ($failed) {
if ($failed instanceof ArcanistNoEffectException) {
if ($renderer instanceof ArcanistLintNoneRenderer) {
return 0;
}
}
throw $failed;
}
$unresolved = array();
$has_warnings = false;
$has_errors = false;
foreach ($results as $result) {
foreach ($result->getMessages() as $message) {
if (!$message->isPatchApplied()) {
if ($message->isError()) {
$has_errors = true;
} else if ($message->isWarning()) {
$has_warnings = true;
}
$unresolved[] = $message;
}
}
}
$this->unresolvedMessages = $unresolved;
$cache = $this->readScratchJSONFile('lint-cache.json');
$cached = idx($cache, $this->getCacheKey(), array());
if ($cached || $use_cache) {
$stopped = $engine->getStoppedPaths();
foreach ($results as $result) {
$path = $result->getPath();
if (!$use_cache) {
unset($cached[$path]);
continue;
}
$abs_path = $engine->getFilePathOnDisk($path);
if (!Filesystem::pathExists($abs_path)) {
continue;
}
$version = $result->getCacheVersion();
$cached_path = array();
if (isset($stopped[$path])) {
$cached_path['stopped'] = $stopped[$path];
}
$cached_path['repository_version'] = $this->getRepositoryVersion();
foreach ($result->getMessages() as $message) {
$granularity = $message->getGranularity();
if ($granularity == ArcanistLinter::GRANULARITY_GLOBAL) {
continue;
}
if (!$message->isPatchApplied()) {
$cached_path[] = $message->toDictionary();
}
}
$hash = idx($file_hashes, $abs_path);
if (!$hash) {
$hash = md5_file($abs_path);
}
$cached[$path] = array($hash => array($version => $cached_path));
}
$cache[$this->getCacheKey()] = $cached;
// TODO: Garbage collection.
$this->writeScratchJSONFile('lint-cache.json', $cache);
}
// Take the most severe lint message severity and use that
// as the result code.
if ($has_errors) {
$result_code = self::RESULT_ERRORS;
} else if ($has_warnings) {
$result_code = self::RESULT_WARNINGS;
} else if (!empty($this->postponedLinters)) {
$result_code = self::RESULT_POSTPONED;
} else {
$result_code = self::RESULT_OKAY;
}
if (!$this->getParentWorkflow()) {
if ($result_code == self::RESULT_OKAY) {
$console->writeOut('%s', $renderer->renderOkayResult());
}
}
return $result_code;
}
public function getUnresolvedMessages() {
return $this->unresolvedMessages;
}
public function getPostponedLinters() {
return $this->postponedLinters;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Jan 19 2025, 21:30 (6 w, 1 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1128941
Default Alt Text
(98 KB)

Event Timeline