Page MenuHomeSealhub

No OneTemporary

diff --git a/src/parser/ArcanistDiffParser.php b/src/parser/ArcanistDiffParser.php
index 180ce700..146606ee 100644
--- a/src/parser/ArcanistDiffParser.php
+++ b/src/parser/ArcanistDiffParser.php
@@ -1,1288 +1,1304 @@
<?php
/**
* Parses diffs from a working copy.
*
* @group diff
*/
final class ArcanistDiffParser {
protected $repositoryAPI;
protected $text;
protected $line;
protected $lineSaved;
protected $isGit;
protected $isMercurial;
protected $detectBinaryFiles = false;
protected $tryEncoding;
protected $rawDiff;
protected $writeDiffOnFailure;
protected $changes = array();
private $forcePath;
public function setRepositoryAPI(ArcanistRepositoryAPI $repository_api) {
$this->repositoryAPI = $repository_api;
return $this;
}
public function setDetectBinaryFiles($detect) {
$this->detectBinaryFiles = $detect;
return $this;
}
public function setTryEncoding($encoding) {
$this->tryEncoding = $encoding;
return $this;
}
public function forcePath($path) {
$this->forcePath = $path;
return $this;
}
public function setChanges(array $changes) {
assert_instances_of($changes, 'ArcanistDiffChange');
$this->changes = mpull($changes, null, 'getCurrentPath');
return $this;
}
public function parseSubversionDiff(ArcanistSubversionAPI $api, $paths) {
$this->setRepositoryAPI($api);
$diffs = array();
foreach ($paths as $path => $status) {
if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED ||
$status & ArcanistRepositoryAPI::FLAG_CONFLICT ||
$status & ArcanistRepositoryAPI::FLAG_MISSING) {
unset($paths[$path]);
}
}
$root = null;
$from = array();
foreach ($paths as $path => $status) {
$change = $this->buildChange($path);
if ($status & ArcanistRepositoryAPI::FLAG_ADDED) {
$change->setType(ArcanistDiffChangeType::TYPE_ADD);
} else if ($status & ArcanistRepositoryAPI::FLAG_DELETED) {
$change->setType(ArcanistDiffChangeType::TYPE_DELETE);
} else {
$change->setType(ArcanistDiffChangeType::TYPE_CHANGE);
}
$is_dir = is_dir($api->getPath($path));
if ($is_dir) {
$change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY);
// We have to go hit the diff even for directories because they may
// have property changes or moves, etc.
}
$is_link = is_link($api->getPath($path));
if ($is_link) {
$change->setFileType(ArcanistDiffChangeType::FILE_SYMLINK);
}
$diff = $api->getRawDiffText($path);
if ($diff) {
$this->parseDiff($diff);
}
$info = $api->getSVNInfo($path);
if (idx($info, 'Copied From URL')) {
if (!$root) {
$rinfo = $api->getSVNInfo('.');
$root = $rinfo['URL'].'/';
}
$cpath = $info['Copied From URL'];
$root_len = strlen($root);
if (!strncmp($cpath, $root, $root_len)) {
$cpath = substr($cpath, $root_len);
// The user can "svn cp /path/to/file@12345 x", which pulls a file out
// of version history at a specific revision. If we just use the path,
// we'll collide with possible changes to that path in the working
// copy below. In particular, "svn cp"-ing a path which no longer
// exists somewhere in the working copy and then adding that path
// gets us to the "origin change type" branches below with a
// TYPE_ADD state on the path. To avoid this, append the origin
// revision to the path so we'll necessarily generate a new change.
// TODO: In theory, you could have an '@' in your path and this could
// cause a collision, e.g. two files named 'f' and 'f@12345'. This is
// at least somewhat the user's fault, though.
if ($info['Copied From Rev']) {
if ($info['Copied From Rev'] != $info['Revision']) {
$cpath .= '@'.$info['Copied From Rev'];
}
}
$change->setOldPath($cpath);
$from[$path] = $cpath;
}
}
$type = $change->getType();
if (($type === ArcanistDiffChangeType::TYPE_MOVE_AWAY ||
$type === ArcanistDiffChangeType::TYPE_DELETE) &&
idx($info, 'Node Kind') === 'directory') {
$change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY);
}
}
foreach ($paths as $path => $status) {
$change = $this->buildChange($path);
if (empty($from[$path])) {
continue;
}
if (empty($this->changes[$from[$path]])) {
if ($change->getType() == ArcanistDiffChangeType::TYPE_COPY_HERE) {
// If the origin path wasn't changed (or isn't included in this diff)
// and we only copied it, don't generate a changeset for it. This
// keeps us out of trouble when we go to 'arc commit' and need to
// figure out which files should be included in the commit list.
continue;
}
}
$origin = $this->buildChange($from[$path]);
$origin->addAwayPath($change->getCurrentPath());
$type = $origin->getType();
switch ($type) {
case ArcanistDiffChangeType::TYPE_MULTICOPY:
case ArcanistDiffChangeType::TYPE_COPY_AWAY:
// "Add" is possible if you do some bizarre tricks with svn:ignore and
// "svn copy"'ing URLs straight from the repository; you can end up with
// a file that is a copy of itself. See T271.
case ArcanistDiffChangeType::TYPE_ADD:
break;
case ArcanistDiffChangeType::TYPE_DELETE:
$origin->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY);
break;
case ArcanistDiffChangeType::TYPE_MOVE_AWAY:
$origin->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
break;
case ArcanistDiffChangeType::TYPE_CHANGE:
$origin->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY);
break;
default:
throw new Exception("Bad origin state {$type}.");
}
$type = $origin->getType();
switch ($type) {
case ArcanistDiffChangeType::TYPE_MULTICOPY:
case ArcanistDiffChangeType::TYPE_MOVE_AWAY:
$change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE);
break;
case ArcanistDiffChangeType::TYPE_ADD:
case ArcanistDiffChangeType::TYPE_COPY_AWAY:
$change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE);
break;
default:
throw new Exception("Bad origin state {$type}.");
}
}
return $this->changes;
}
public function parseDiff($diff) {
if (!strlen(trim($diff))) {
throw new Exception("Can't parse an empty diff!");
}
$this->didStartParse($diff);
do {
$patterns = array(
// This is a normal SVN text change, probably from "svn diff".
'(?P<type>Index): (?P<cur>.+)',
// This is an SVN property change, probably from "svn diff".
'(?P<type>Property changes on): (?P<cur>.+)',
// This is a git commit message, probably from "git show".
'(?P<type>commit) (?P<hash>[a-f0-9]+)(?: \(.*\))?',
// This is a git diff, probably from "git show" or "git diff".
// Note that the filenames may appear quoted.
'(?P<type>diff --git) (?P<oldnew>.*)',
// This is a unified diff, probably from "diff -u" or synthetic diffing.
'(?P<type>---) (?P<old>.+)\s+\d{4}-\d{2}-\d{2}.*',
'(?P<binary>Binary) files '.
'(?P<old>.+)\s+\d{4}-\d{2}-\d{2} and '.
'(?P<new>.+)\s+\d{4}-\d{2}-\d{2} differ.*',
// This is a normal Mercurial text change, probably from "hg diff". It
// may have two "-r" blocks if it came from "hg diff -r x:y".
'(?P<type>diff -r) (?P<hgrev>[a-f0-9]+) (?:-r [a-f0-9]+ )?(?P<cur>.+)',
);
$line = $this->getLineTrimmed();
$match = null;
$ok = $this->tryMatchHeader($patterns, $line, $match);
$failed_parse = false;
if (!$ok && $this->isFirstNonEmptyLine()) {
// 'hg export' command creates so called "extended diff" that
// contains some meta information and comment at the beginning
// (isFirstNonEmptyLine() to check for beginning). Actual mercurial
// code detects where comment ends and unified diff starts by
// searching for "diff -r" or "diff --git" in the text.
$this->saveLine();
$line = $this->nextLineThatLooksLikeDiffStart();
if (!$this->tryMatchHeader($patterns, $line, $match)) {
// Restore line before guessing to display correct error.
$this->restoreLine();
$failed_parse = true;
}
} else if (!$ok) {
$failed_parse = true;
}
if ($failed_parse) {
$this->didFailParse(
"Expected a hunk header, like 'Index: /path/to/file.ext' (svn), ".
"'Property changes on: /path/to/file.ext' (svn properties), ".
"'commit 59bcc3ad6775562f845953cf01624225' (git show), ".
"'diff --git' (git diff), '--- filename' (unified diff), or " .
"'diff -r' (hg diff or patch).");
}
if (isset($match['type'])) {
if ($match['type'] == 'diff --git') {
list($old, $new) = self::splitGitDiffPaths($match['oldnew']);
$match['old'] = $old;
$match['cur'] = $new;
}
}
$change = $this->buildChange(idx($match, 'cur'));
if (isset($match['old'])) {
$change->setOldPath($match['old']);
}
if (isset($match['hash'])) {
$change->setCommitHash($match['hash']);
}
if (isset($match['binary'])) {
$change->setFileType(ArcanistDiffChangeType::FILE_BINARY);
$line = $this->nextNonemptyLine();
continue;
}
$line = $this->nextLine();
switch ($match['type']) {
case 'Index':
$this->parseIndexHunk($change);
break;
case 'Property changes on':
$this->parsePropertyHunk($change);
break;
case 'diff --git':
$this->setIsGit(true);
$this->parseIndexHunk($change);
break;
case 'commit':
$this->setIsGit(true);
$this->parseCommitMessage($change);
break;
case '---':
$ok = preg_match(
'@^(?:\+\+\+) (.*)\s+\d{4}-\d{2}-\d{2}.*$@',
$line,
$match);
if (!$ok) {
$this->didFailParse("Expected '+++ filename' in unified diff.");
}
$change->setCurrentPath($match[1]);
$line = $this->nextLine();
$this->parseChangeset($change);
break;
case 'diff -r':
$this->setIsMercurial(true);
$this->parseIndexHunk($change);
break;
default:
$this->didFailParse("Unknown diff type.");
break;
}
} while ($this->getLine() !== null);
$this->didFinishParse();
$this->loadSyntheticData();
return $this->changes;
}
protected function tryMatchHeader($patterns, $line, &$match) {
foreach ($patterns as $pattern) {
if (preg_match('@^'.$pattern.'$@', $line, $match)) {
return true;
}
}
return false;
}
protected function parseCommitMessage(ArcanistDiffChange $change) {
$change->setType(ArcanistDiffChangeType::TYPE_MESSAGE);
$message = array();
$line = $this->getLine();
if (preg_match('/^Merge: /', $line)) {
$this->nextLine();
}
$line = $this->getLine();
if (!preg_match('/^Author: /', $line)) {
$this->didFailParse("Expected 'Author:'.");
}
$line = $this->nextLine();
if (!preg_match('/^Date: /', $line)) {
$this->didFailParse("Expected 'Date:'.");
}
while (($line = $this->nextLineTrimmed()) !== null) {
if (strlen($line) && $line[0] != ' ') {
break;
}
// Strip leading spaces from Git commit messages. Note that empty lines
// are represented as just "\n"; don't touch those.
$message[] = preg_replace('/^ /', '', $this->getLine());
}
$message = rtrim(implode('', $message), "\r\n");
$change->setMetadata('message', $message);
}
/**
* Parse an SVN property change hunk. These hunks are ambiguous so just sort
* of try to get it mostly right. It's entirely possible to foil this parser
* (or any other parser) with a carefully constructed property change.
*/
protected function parsePropertyHunk(ArcanistDiffChange $change) {
$line = $this->getLineTrimmed();
if (!preg_match('/^_+$/', $line)) {
$this->didFailParse("Expected '______________________'.");
}
$line = $this->nextLine();
while ($line !== null) {
$done = preg_match('/^(Index|Property changes on):/', $line);
if ($done) {
break;
}
// NOTE: Before 1.5, SVN uses "Name". At 1.5 and later, SVN uses
// "Modified", "Added" and "Deleted".
$matches = null;
$ok = preg_match(
'/^(Name|Modified|Added|Deleted): (.*)$/',
$line,
$matches);
if (!$ok) {
$this->didFailParse(
"Expected 'Name', 'Added', 'Deleted', or 'Modified'.");
}
$op = $matches[1];
$prop = $matches[2];
list($old, $new) = $this->parseSVNPropertyChange($op, $prop);
if ($old !== null) {
$change->setOldProperty($prop, $old);
}
if ($new !== null) {
$change->setNewProperty($prop, $new);
}
$line = $this->getLine();
}
}
private function parseSVNPropertyChange($op, $prop) {
$old = array();
$new = array();
$target = null;
$line = $this->nextLine();
$prop_index = 2;
while ($line !== null) {
$done = preg_match(
'/^(Modified|Added|Deleted|Index|Property changes on):/',
$line);
if ($done) {
break;
}
$trimline = ltrim($line);
if ($trimline && $trimline[0] == '#') {
// in svn1.7, a line like ## -0,0 +1 ## is put between the Added: line
// and the line with the property change. If we have such a line, we'll
// just ignore it (:
$line = $this->nextLine();
$prop_index = 1;
$trimline = ltrim($line);
}
if ($trimline && $trimline[0] == '+') {
if ($op == 'Deleted') {
$this->didFailParse('Unexpected "+" section in property deletion.');
}
$target = 'new';
$line = substr($trimline, $prop_index);
} else if ($trimline && $trimline[0] == '-') {
if ($op == 'Added') {
$this->didFailParse('Unexpected "-" section in property addition.');
}
$target = 'old';
$line = substr($trimline, $prop_index);
} else if (!strncmp($trimline, 'Merged', 6)) {
if ($op == 'Added') {
$target = 'new';
} else {
// These can appear on merges. No idea how to interpret this (unclear
// what the old / new values are) and it's of dubious usefulness so
// just throw it away until someone complains.
$target = null;
}
$line = $trimline;
}
if ($target == 'new') {
$new[] = $line;
} else if ($target == 'old') {
$old[] = $line;
}
$line = $this->nextLine();
}
$old = rtrim(implode('', $old));
$new = rtrim(implode('', $new));
if (!strlen($old)) {
$old = null;
}
if (!strlen($new)) {
$new = null;
}
return array($old, $new);
}
protected function setIsGit($git) {
if ($this->isGit !== null && $this->isGit != $git) {
throw new Exception("Git status has changed!");
}
$this->isGit = $git;
return $this;
}
protected function getIsGit() {
return $this->isGit;
}
public function setIsMercurial($is_mercurial) {
$this->isMercurial = $is_mercurial;
return $this;
}
public function getIsMercurial() {
return $this->isMercurial;
}
protected function parseIndexHunk(ArcanistDiffChange $change) {
$is_git = $this->getIsGit();
$is_mercurial = $this->getIsMercurial();
$is_svn = (!$is_git && !$is_mercurial);
$move_source = null;
$line = $this->getLine();
if ($is_git) {
do {
$patterns = array(
'(?P<new>new) file mode (?P<newmode>\d+)',
'(?P<deleted>deleted) file mode (?P<oldmode>\d+)',
// These occur when someone uses `chmod` on a file.
'old mode (?P<oldmode>\d+)',
'new mode (?P<newmode>\d+)',
// These occur when you `mv` a file and git figures it out.
'similarity index ',
'rename from (?P<old>.*)',
'(?P<move>rename) to (?P<cur>.*)',
'copy from (?P<old>.*)',
'(?P<copy>copy) to (?P<cur>.*)'
);
$ok = false;
$match = null;
foreach ($patterns as $pattern) {
$ok = preg_match('@^'.$pattern.'@', $line, $match);
if ($ok) {
break;
}
}
if (!$ok) {
if ($line === null ||
preg_match('/^(diff --git|commit) /', $line)) {
// In this case, there are ONLY file mode changes, or this is a
// pure move. If it's a move, flag these changesets so we can build
// synthetic changes later, enabling us to show file contents in
// Differential -- git only gives us a block like this:
//
// diff --git a/README b/READYOU
// similarity index 100%
// rename from README
// rename to READYOU
//
// ...i.e., there is no associated diff.
// This allows us to distinguish between property changes only
// and actual moves. For property changes only, we can't currently
// build a synthetic diff correctly, so just skip it.
// TODO: Build synthetic diffs for property changes, too.
if ($change->getType() != ArcanistDiffChangeType::TYPE_CHANGE) {
$change->setNeedsSyntheticGitHunks(true);
if ($move_source) {
$move_source->setNeedsSyntheticGitHunks(true);
}
}
return;
}
break;
}
if (!empty($match['oldmode'])) {
$change->setOldProperty('unix:filemode', $match['oldmode']);
}
if (!empty($match['newmode'])) {
$change->setNewProperty('unix:filemode', $match['newmode']);
}
if (!empty($match['deleted'])) {
$change->setType(ArcanistDiffChangeType::TYPE_DELETE);
}
if (!empty($match['new'])) {
// If you replace a symlink with a normal file, git renders the change
// as a "delete" of the symlink plus an "add" of the new file. We
// prefer to represent this as a change.
if ($change->getType() == ArcanistDiffChangeType::TYPE_DELETE) {
$change->setType(ArcanistDiffChangeType::TYPE_CHANGE);
} else {
$change->setType(ArcanistDiffChangeType::TYPE_ADD);
}
}
if (!empty($match['old'])) {
$match['old'] = self::unescapeFilename($match['old']);
$change->setOldPath($match['old']);
}
if (!empty($match['cur'])) {
$match['cur'] = self::unescapeFilename($match['cur']);
$change->setCurrentPath($match['cur']);
}
if (!empty($match['copy'])) {
$change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE);
$old = $this->buildChange($change->getOldPath());
$type = $old->getType();
if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) {
$old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
} else {
$old->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY);
}
$old->addAwayPath($change->getCurrentPath());
}
if (!empty($match['move'])) {
$change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE);
$old = $this->buildChange($change->getOldPath());
$type = $old->getType();
if ($type == ArcanistDiffChangeType::TYPE_MULTICOPY) {
// Great, no change.
} else if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) {
$old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
} else if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) {
$old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
} else {
$old->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY);
}
// We'll reference this above.
$move_source = $old;
$old->addAwayPath($change->getCurrentPath());
}
$line = $this->nextNonemptyLine();
} while (true);
}
$line = $this->getLine();
if ($is_svn) {
$ok = preg_match('/^=+\s*$/', $line);
if (!$ok) {
$this->didFailParse("Expected '=======================' divider line.");
} else {
// Adding an empty file in SVN can produce an empty line here.
$line = $this->nextNonemptyLine();
}
} else if ($is_git) {
$ok = preg_match('/^index .*$/', $line);
if (!$ok) {
// TODO: "hg diff -g" diffs ("mercurial git-style diffs") do not include
// this line, so we can't parse them if we fail on it. Maybe introduce
// a flag saying "parse this diff using relaxed git-style diff rules"?
// $this->didFailParse("Expected 'index af23f...a98bc' header line.");
} else {
// NOTE: In the git case, where this patch is the last change in the
// file, we may have a final terminal newline. Skip over it so that
// we'll hit the '$line === null' block below. This is covered by the
// 'git-empty-file.gitdiff' test case.
$line = $this->nextNonemptyLine();
}
}
// If there are files with only whitespace changes and -b or -w are
// supplied as command-line flags to `diff', svn and git both produce
// changes without any body.
if ($line === null ||
preg_match(
'/^(Index:|Property changes on:|diff --git|commit) /',
$line)) {
return;
}
$is_binary_add = preg_match(
'/^Cannot display: file marked as a binary type\.$/',
rtrim($line));
if ($is_binary_add) {
$this->nextLine(); // Cannot display: file marked as a binary type.
$this->nextNonemptyLine(); // svn:mime-type = application/octet-stream
$this->markBinary($change);
return;
}
// We can get this in git, or in SVN when a file exists in the repository
// WITHOUT a binary mime-type and is changed and given a binary mime-type.
$is_binary_diff = preg_match(
'/^Binary files .* and .* differ$/',
rtrim($line));
if ($is_binary_diff) {
$this->nextNonemptyLine(); // Binary files x and y differ
$this->markBinary($change);
return;
}
// This occurs under "hg diff --git" when a binary file is removed. See
// test case "hg-binary-delete.hgdiff". (I believe it never occurs under
// git, which reports the "files X and /dev/null differ" string above. Git
// can not apply these patches.)
$is_hg_binary_delete = preg_match(
'/^Binary file .* has changed$/',
rtrim($line));
if ($is_hg_binary_delete) {
$this->nextNonemptyLine();
$this->markBinary($change);
return;
}
// With "git diff --binary" (not a normal mode, but one users may explicitly
// invoke and then, e.g., copy-paste into the web console) or "hg diff
// --git" (normal under hg workflows), we may encounter a literal binary
// patch.
$is_git_binary_patch = preg_match(
'/^GIT binary patch$/',
rtrim($line));
if ($is_git_binary_patch) {
$this->nextLine();
$this->parseGitBinaryPatch();
$line = $this->getLine();
if (preg_match('/^literal/', $line)) {
// We may have old/new binaries (change) or just a new binary (hg add).
// If there are two blocks, parse both.
$this->parseGitBinaryPatch();
}
$this->markBinary($change);
return;
}
if ($is_git) {
// "git diff -b" ignores whitespace, but has an empty hunk target
if (preg_match('@^diff --git .*$@', $line)) {
$this->nextLine();
return null;
}
}
$old_file = $this->parseHunkTarget();
$new_file = $this->parseHunkTarget();
$change->setOldPath($old_file);
$this->parseChangeset($change);
}
private function parseGitBinaryPatch() {
// TODO: We could decode the patches, but it's a giant mess so don't bother
// for now. We'll pick up the data from the working copy in the common
// case ("arc diff").
$line = $this->getLine();
if (!preg_match('/^literal /', $line)) {
$this->didFailParse("Expected 'literal NNNN' to start git binary patch.");
}
do {
$line = $this->nextLineTrimmed();
if ($line === '' || $line === null) {
// Some versions of Mercurial apparently omit the terminal newline,
// although it's unclear if Git will ever do this. In either case,
// rely on the base85 check for sanity.
$this->nextNonemptyLine();
return;
} else if (!preg_match('/^[a-zA-Z]/', $line)) {
$this->didFailParse("Expected base85 line length character (a-zA-Z).");
}
} while (true);
}
protected function parseHunkTarget() {
$line = $this->getLine();
$matches = null;
$remainder = '(?:\s*\(.*\))?';
if ($this->getIsMercurial()) {
// Something like "Fri Aug 26 01:20:50 2005 -0700", don't bother trying
// to parse it.
$remainder = '\t.*';
}
$ok = preg_match(
'@^[-+]{3} (?:[ab]/)?(?P<path>.*?)'.$remainder.'$@',
$line,
$matches);
if (!$ok) {
$this->didFailParse(
"Expected hunk target '+++ path/to/file.ext (revision N)'.");
}
$this->nextLine();
return $matches['path'];
}
protected function markBinary(ArcanistDiffChange $change) {
$change->setFileType(ArcanistDiffChangeType::FILE_BINARY);
return $this;
}
protected function parseChangeset(ArcanistDiffChange $change) {
$all_changes = array();
do {
$hunk = new ArcanistDiffHunk();
$line = $this->getLineTrimmed();
$real = array();
// In the case where only one line is changed, the length is omitted.
// The final group is for git, which appends a guess at the function
// context to the diff.
$matches = null;
$ok = preg_match(
'/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*?)?$/U',
$line,
$matches);
if (!$ok) {
// It's possible we hit the style of an svn1.7 property change.
// This is a 4-line Index block, followed by an empty line, followed
// by a "Property changes on:" section similar to svn1.6.
if ($line == '') {
$line = $this->nextNonemptyLine();
$ok = preg_match('/^Property changes on:/', $line);
if (!$ok) {
$this->didFailParse("Confused by empty line");
}
$line = $this->nextLine();
return $this->parsePropertyHunk($change);
}
$this->didFailParse("Expected hunk header '@@ -NN,NN +NN,NN @@'.");
}
$hunk->setOldOffset($matches[1]);
$hunk->setNewOffset($matches[3]);
// Cover for the cases where length wasn't present (implying one line).
$old_len = idx($matches, 2);
if (!strlen($old_len)) {
$old_len = 1;
}
$new_len = idx($matches, 4);
if (!strlen($new_len)) {
$new_len = 1;
}
$hunk->setOldLength($old_len);
$hunk->setNewLength($new_len);
$add = 0;
$del = 0;
$hit_next_hunk = false;
while ((($line = $this->nextLine()) !== null)) {
if (strlen(rtrim($line, "\r\n"))) {
$char = $line[0];
} else {
// Normally, we do not encouter empty lines in diffs, because
// unchanged lines have an initial space. However, in Git, with
// the option `diff.suppress-blank-empty` set, unchanged blank lines
// emit as completely empty. If we encounter a completely empty line,
// treat it as a ' ' (i.e., unchanged empty line) line.
$char = ' ';
}
switch ($char) {
case '\\':
if (!preg_match('@\\ No newline at end of file@', $line)) {
$this->didFailParse(
"Expected '\ No newline at end of file'.");
}
if ($new_len) {
$real[] = $line;
$hunk->setIsMissingOldNewline(true);
} else {
$real[] = $line;
$hunk->setIsMissingNewNewline(true);
}
if (!$new_len) {
break 2;
}
break;
case '+':
++$add;
--$new_len;
$real[] = $line;
break;
case '-':
if (!$old_len) {
// In this case, we've hit "---" from a new file. So don't
// advance the line cursor.
$hit_next_hunk = true;
break 2;
}
++$del;
--$old_len;
$real[] = $line;
break;
case ' ':
if (!$old_len && !$new_len) {
break 2;
}
--$old_len;
--$new_len;
$real[] = $line;
break;
default:
// We hit something, likely another hunk.
$hit_next_hunk = true;
break 2;
}
}
if ($old_len || $new_len) {
$this->didFailParse("Found the wrong number of hunk lines.");
}
$corpus = implode('', $real);
$is_binary = false;
if ($this->detectBinaryFiles) {
$is_binary = !phutil_is_utf8($corpus);
$try_encoding = $this->tryEncoding;
if ($is_binary && $try_encoding) {
$is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus);
if (!$is_binary) {
$corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding);
if (!phutil_is_utf8($corpus)) {
throw new Exception(
"Failed to convert a hunk from '{$try_encoding}' to UTF-8. ".
"Check that the specified encoding is correct.");
}
}
}
}
if ($is_binary) {
// SVN happily treats binary files which aren't marked with the right
// mime type as text files. Detect that junk here and mark the file
// binary. We'll catch stuff with unicode too, but that's verboten
// anyway. If there are too many false positives with this we might
// need to make it threshold-triggered instead of triggering on any
// unprintable byte.
$change->setFileType(ArcanistDiffChangeType::FILE_BINARY);
} else {
$hunk->setCorpus($corpus);
$hunk->setAddLines($add);
$hunk->setDelLines($del);
$change->addHunk($hunk);
}
if (!$hit_next_hunk) {
$line = $this->nextNonemptyLine();
}
} while (preg_match('/^@@ /', $line));
}
protected function buildChange($path = null) {
$change = null;
if ($path !== null) {
if (!empty($this->changes[$path])) {
return $this->changes[$path];
}
}
if ($this->forcePath) {
return $this->changes[$this->forcePath];
}
$change = new ArcanistDiffChange();
if ($path !== null) {
$change->setCurrentPath($path);
$this->changes[$path] = $change;
} else {
$this->changes[] = $change;
}
return $change;
}
protected function didStartParse($text) {
$this->rawDiff = $text;
// Eat leading whitespace. This may happen if the first change in the diff
// is an SVN property change.
$text = ltrim($text);
// Try to strip ANSI color codes from colorized diffs. ANSI color codes
// might be present in two cases:
//
// - You piped a colorized diff into 'arc --raw' or similar (normally
// we're able to disable colorization on diffs we control the generation
// of).
// - You're diffing a file which actually contains ANSI color codes.
//
// The former is vastly more likely, but we try to distinguish between the
// two cases by testing for a color code at the beginning of a line. If
// we find one, we know it's a colorized diff (since the beginning of the
// line should be "+", "-" or " " if the code is in the diff text).
//
// While it's possible a diff might be colorized and fail this test, it's
// unlikely, and it covers hg's color extension which seems to be the most
// stubborn about colorizing text despite stdout not being a TTY.
//
// We might incorrectly strip color codes from a colorized diff of a text
// file with color codes inside it, but this case is stupid and pathological
// and you've dug your own grave.
$ansi_color_pattern = '\x1B\[[\d;]*m';
if (preg_match('/^'.$ansi_color_pattern.'/m', $text)) {
$text = preg_replace('/'.$ansi_color_pattern.'/', '', $text);
}
$this->text = phutil_split_lines($text);
$this->line = 0;
}
protected function getLine() {
if ($this->text === null) {
throw new Exception("Not parsing!");
}
if (isset($this->text[$this->line])) {
return $this->text[$this->line];
}
return null;
}
protected function getLineTrimmed() {
$line = $this->getLine();
if ($line !== null) {
$line = trim($line, "\r\n");
}
return $line;
}
protected function nextLine() {
$this->line++;
return $this->getLine();
}
protected function nextLineTrimmed() {
$line = $this->nextLine();
if ($line !== null) {
$line = trim($line, "\r\n");
}
return $line;
}
protected function nextNonemptyLine() {
while (($line = $this->nextLine()) !== null) {
if (strlen(trim($line)) !== 0) {
break;
}
}
return $this->getLine();
}
protected function nextLineThatLooksLikeDiffStart() {
while (($line = $this->nextLine()) !== null) {
if (preg_match('/^\s*diff\s+-(?:r|-git)/', $line)) {
break;
}
}
return $this->getLine();
}
protected function saveLine() {
$this->lineSaved = $this->line;
}
protected function restoreLine() {
$this->line = $this->lineSaved;
}
protected function isFirstNonEmptyLine() {
$count = count($this->text);
for ($i = 0; $i < $count; $i++) {
if (strlen(trim($this->text[$i])) != 0) {
return ($i == $this->line);
}
}
// Entire file is empty.
return false;
}
protected function didFinishParse() {
$this->text = null;
}
public function setWriteDiffOnFailure($write) {
$this->writeDiffOnFailure = $write;
return $this;
}
protected function didFailParse($message) {
$context = 5;
$min = max(0, $this->line - $context);
$max = min($this->line + $context, count($this->text) - 1);
$context = '';
for ($ii = $min; $ii <= $max; $ii++) {
$context .= sprintf(
"%8.8s %6.6s %s",
($ii == $this->line) ? '>>> ' : '',
$ii + 1,
$this->text[$ii]);
}
$out = array();
$out[] = "Diff Parse Exception: {$message}";
if ($this->writeDiffOnFailure) {
$temp = new TempFile();
$temp->setPreserveFile(true);
Filesystem::writeFile($temp, $this->rawDiff);
$out[] = "Raw input file was written to: ".(string)$temp;
}
$out[] = $context;
$out = implode("\n\n", $out);
throw new Exception($out);
}
/**
* Unescape escaped filenames, e.g. from "git diff".
*/
private static function unescapeFilename($name) {
if (preg_match('/^".+"$/', $name)) {
return stripcslashes(substr($name, 1, -1));
} else {
return $name;
}
}
private function loadSyntheticData() {
if (!$this->changes) {
return;
}
$repository_api = $this->repositoryAPI;
if (!$repository_api) {
return;
}
+ $imagechanges = array();
+
$changes = $this->changes;
foreach ($changes as $change) {
$path = $change->getCurrentPath();
// Certain types of changes (moves and copies) don't contain change data
// when expressed in raw "git diff" form. Augment any such diffs with
// textual data.
if ($change->getNeedsSyntheticGitHunks() &&
($repository_api instanceof ArcanistGitAPI)) {
$diff = $repository_api->getRawDiffText($path, $moves = false);
// NOTE: We're reusing the parser and it doesn't reset change state
// between parses because there's an oddball SVN workflow in Phabricator
// which relies on being able to inject changes.
// TODO: Fix this.
$parser = clone $this;
$parser->setChanges(array());
$raw_changes = $parser->parseDiff($diff);
foreach ($raw_changes as $raw_change) {
if ($raw_change->getCurrentPath() == $path) {
$change->setFileType($raw_change->getFileType());
foreach ($raw_change->getHunks() as $hunk) {
// Git thinks that this file has been added. But we know that it
// has been moved or copied without a change.
$hunk->setCorpus(
preg_replace('/^\+/m', ' ', $hunk->getCorpus()));
$change->addHunk($hunk);
}
break;
}
}
$change->setNeedsSyntheticGitHunks(false);
}
if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY &&
$change->getFileType() != ArcanistDiffChangeType::FILE_IMAGE) {
continue;
}
- $change->setOriginalFileData($repository_api->getOriginalFileData($path));
- $change->setCurrentFileData($repository_api->getCurrentFileData($path));
+ $imagechanges[$path] = $change;
+ }
+
+ // Fetch the actual file contents in batches so repositories
+ // that have slow random file accesses (i.e. mercurial) can
+ // optimize the retrieval.
+ $paths = array_keys($imagechanges);
+
+ $filedata = $repository_api->getBulkOriginalFileData($paths);
+ foreach ($filedata as $path => $data) {
+ $imagechanges[$path]->setOriginalFileData($data);
+ }
+
+ $filedata = $repository_api->getBulkCurrentFileData($paths);
+ foreach ($filedata as $path => $data) {
+ $imagechanges[$path]->setCurrentFileData($data);
}
$this->changes = $changes;
}
/**
* Strip prefixes off paths from `git diff`. By default git uses a/ and b/,
* but you can set `diff.mnemonicprefix` to get a different set of prefixes,
* or use `--no-prefix`, `--src-prefix` or `--dst-prefix` to set these to
* other arbitrary values.
*
* We strip the default and mnemonic prefixes, and trust the user knows what
* they're doing in the other cases.
*
* @param string Path to strip.
* @return string Stripped path.
*/
public static function stripGitPathPrefix($path) {
static $regex;
if ($regex === null) {
$prefixes = array(
// These are the defaults.
'a/',
'b/',
// These show up when you set "diff.mnemonicprefix".
'i/',
'c/',
'w/',
'o/',
'1/',
'2/',
);
foreach ($prefixes as $key => $prefix) {
$prefixes[$key] = preg_quote($prefix, '@');
}
$regex = '@^('.implode('|', $prefixes).')@S';
}
return preg_replace($regex, '', $path);
}
/**
* Split the paths on a "diff --git" line into old and new paths. This
* is difficult because they may be ambiguous if the files contain spaces.
*
* @param string Text from a diff line after "diff --git ".
* @return pair<string, string> Old and new paths.
*/
public static function splitGitDiffPaths($paths) {
$matches = null;
$paths = rtrim($paths, "\r\n");
$patterns = array(
// Try quoted paths, used for unicode filenames or filenames with quotes.
'@^(?P<old>"(?:\\\\.|[^"\\\\]+)+") (?P<new>"(?:\\\\.|[^"\\\\]+)+")$@',
// Try paths without spaces.
'@^(?P<old>[^ ]+) (?P<new>[^ ]+)$@',
// Try paths with well-known prefixes.
'@^(?P<old>[abicwo12]/.*) (?P<new>[abicwo12]/.*)$@',
// Try the exact same string twice in a row separated by a space.
// This can hit a false positive for moves from files like "old file old"
// to "file", but such a case combined with custom diff prefixes is
// incredibly obscure.
'@^(?P<old>.*) (?P<new>\\1)$@',
);
foreach ($patterns as $pattern) {
if (preg_match($pattern, $paths, $matches)) {
break;
}
}
if (!$matches) {
throw new Exception(
"Input diff contains ambiguous line 'diff --git {$paths}'. This line ".
"is ambiguous because there are spaces in the file names, so the ".
"parser can not determine where the file names begin and end. To ".
"resolve this ambiguity, use standard prefixes ('a/' and 'b/') when ".
"generating diffs.");
}
$old = $matches['old'];
$old = self::unescapeFilename($old);
$old = self::stripGitPathPrefix($old);
$new = $matches['new'];
$new = self::unescapeFilename($new);
$new = self::stripGitPathPrefix($new);
return array($old, $new);
}
}
diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php
index 10de875f..359d61b2 100644
--- a/src/repository/api/ArcanistMercurialAPI.php
+++ b/src/repository/api/ArcanistMercurialAPI.php
@@ -1,907 +1,951 @@
<?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) {
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) {
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 '%C' --rev %s --branch %s --",
"{node}\1{rev}\1{author|emailuser}\1{author|email}\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, $author, $author_email, $date, $branch, $tag,
$parents, $desc) = explode("\1", $log, 9);
// 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() {
return $this->getMercurialConfig('ui.username');
}
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) {
$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) = $this->execManualLocal(
'id -r %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 '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('.^');
}
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 0a170fa7..1d41c677 100644
--- a/src/repository/api/ArcanistRepositoryAPI.php
+++ b/src/repository/api/ArcanistRepositoryAPI.php
@@ -1,625 +1,652 @@
<?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) {
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);
/* -( 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;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Dec 23, 08:19 (1 d, 3 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
556877
Default Alt Text
(88 KB)

Event Timeline