mirror of
https://we.phorge.it/source/arcanist.git
synced 2024-12-02 11:42:41 +01:00
0bf5c3603c
Summary: See D4049, D4096. - Move commit range storage from Mercurial and Git APIs to the base API. - Move caching up to the base level. - Store symbolic name and resolved name separately, so we can re-resolve the correct commit from the symbolic name after dirtying caches. - Rename `supportsRelativeLocalCommit()` to `supportsCommitRanges()` (old name wasn't very good, and not consistent with new terminology like the `--base` flag). - Rename `getRelativeCommit()` and `setRelativeCommit()` to `getBaseCommit()` and `setBaseCommit()`. - Introduce `reloadCommitRange()` and call it from `reloadWorkingCopy()`. I think this fixes the problem in D4049, and provides a general solution for the class of problems we're running into here, with D4096. Specifically: - We no longer get dirty caches, as long as you call reloadWorkingCopy() after changing the working copy (or call a method which calls it for you). - We no longer get order-of-parsing-things problems, because setBaseCommit() reloads the appropriate caches. - We no longer get nasty effects from calling `requireCleanWorkingCopy()` too early. Test Plan: This is pretty far-reaching and hard to test. Unit tests; ran various arc commands. :/ Reviewers: vrana Reviewed By: vrana CC: aran Differential Revision: https://secure.phabricator.com/D4097
618 lines
17 KiB
PHP
618 lines
17 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Interfaces with Subversion working copies.
|
|
*
|
|
* @group workingcopy
|
|
*/
|
|
final class ArcanistSubversionAPI extends ArcanistRepositoryAPI {
|
|
|
|
protected $svnStatus;
|
|
protected $svnBaseRevisions;
|
|
protected $svnInfo = array();
|
|
|
|
protected $svnInfoRaw = array();
|
|
protected $svnDiffRaw = array();
|
|
|
|
private $svnBaseRevisionNumber;
|
|
|
|
public function getSourceControlSystemName() {
|
|
return 'svn';
|
|
}
|
|
|
|
public function getMetadataPath() {
|
|
static $svn_dir = null;
|
|
if ($svn_dir === null) {
|
|
// from svn 1.7, subversion keeps a single .svn directly under
|
|
// the working copy root. However, we allow .arcconfigs that
|
|
// aren't at the working copy root.
|
|
foreach (Filesystem::walkToRoot($this->getPath()) as $parent) {
|
|
$possible_svn_dir = Filesystem::resolvePath('.svn', $parent);
|
|
if (Filesystem::pathExists($possible_svn_dir)) {
|
|
$svn_dir = $possible_svn_dir;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return $svn_dir;
|
|
}
|
|
|
|
protected function buildLocalFuture(array $argv) {
|
|
|
|
$argv[0] = 'svn '.$argv[0];
|
|
|
|
$future = newv('ExecFuture', $argv);
|
|
$future->setCWD($this->getPath());
|
|
return $future;
|
|
}
|
|
|
|
protected function buildCommitRangeStatus() {
|
|
// In SVN, the commit range is always "uncommitted changes", so these
|
|
// statuses are equivalent.
|
|
return $this->getUncommittedStatus();
|
|
}
|
|
|
|
protected function buildUncommittedStatus() {
|
|
return $this->getSVNStatus();
|
|
}
|
|
|
|
public function getSVNBaseRevisions() {
|
|
if ($this->svnBaseRevisions === null) {
|
|
$this->getSVNStatus();
|
|
}
|
|
return $this->svnBaseRevisions;
|
|
}
|
|
|
|
public function getSVNStatus($with_externals = false) {
|
|
if ($this->svnStatus === null) {
|
|
list($status) = $this->execxLocal('--xml status');
|
|
$xml = new SimpleXMLElement($status);
|
|
|
|
if (count($xml->target) != 1) {
|
|
throw new Exception("Expected exactly one XML status target.");
|
|
}
|
|
|
|
$externals = array();
|
|
$files = array();
|
|
|
|
$target = $xml->target[0];
|
|
$this->svnBaseRevisions = array();
|
|
foreach ($target->entry as $entry) {
|
|
$path = (string)$entry['path'];
|
|
$mask = 0;
|
|
|
|
$props = (string)($entry->{'wc-status'}[0]['props']);
|
|
$item = (string)($entry->{'wc-status'}[0]['item']);
|
|
|
|
$base = (string)($entry->{'wc-status'}[0]['revision']);
|
|
$this->svnBaseRevisions[$path] = $base;
|
|
|
|
switch ($props) {
|
|
case 'none':
|
|
case 'normal':
|
|
break;
|
|
case 'modified':
|
|
$mask |= self::FLAG_MODIFIED;
|
|
break;
|
|
default:
|
|
throw new Exception("Unrecognized property status '{$props}'.");
|
|
}
|
|
|
|
$mask |= $this->parseSVNStatus($item);
|
|
if ($item == 'external') {
|
|
$externals[] = $path;
|
|
}
|
|
|
|
// This is new in or around Subversion 1.6.
|
|
$tree_conflicts = (string)($entry->{'wc-status'}[0]['tree-conflicted']);
|
|
if ($tree_conflicts) {
|
|
$mask |= self::FLAG_CONFLICT;
|
|
}
|
|
|
|
$files[$path] = $mask;
|
|
}
|
|
|
|
foreach ($files as $path => $mask) {
|
|
foreach ($externals as $external) {
|
|
if (!strncmp($path, $external, strlen($external))) {
|
|
$files[$path] |= self::FLAG_EXTERNALS;
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->svnStatus = $files;
|
|
}
|
|
|
|
$status = $this->svnStatus;
|
|
if (!$with_externals) {
|
|
foreach ($status as $path => $mask) {
|
|
if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) {
|
|
unset($status[$path]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
private function parseSVNStatus($item) {
|
|
switch ($item) {
|
|
case 'normal':
|
|
return 0;
|
|
case 'external':
|
|
return self::FLAG_EXTERNALS;
|
|
case 'unversioned':
|
|
return self::FLAG_UNTRACKED;
|
|
case 'obstructed':
|
|
return self::FLAG_OBSTRUCTED;
|
|
case 'missing':
|
|
return self::FLAG_MISSING;
|
|
case 'added':
|
|
return self::FLAG_ADDED;
|
|
case 'replaced':
|
|
// This is the result of "svn rm"-ing a file, putting another one
|
|
// in place of it, and then "svn add"-ing the new file. Just treat
|
|
// this as equivalent to "modified".
|
|
return self::FLAG_MODIFIED;
|
|
case 'modified':
|
|
return self::FLAG_MODIFIED;
|
|
case 'deleted':
|
|
return self::FLAG_DELETED;
|
|
case 'conflicted':
|
|
return self::FLAG_CONFLICT;
|
|
case 'incomplete':
|
|
return self::FLAG_INCOMPLETE;
|
|
default:
|
|
throw new Exception("Unrecognized item status '{$item}'.");
|
|
}
|
|
}
|
|
|
|
public function addToCommit(array $paths) {
|
|
$this->execxLocal(
|
|
'add -- %Ls',
|
|
$paths);
|
|
}
|
|
|
|
public function getSVNProperty($path, $property) {
|
|
list($stdout) = execx(
|
|
'svn propget %s %s@',
|
|
$property,
|
|
$this->getPath($path));
|
|
return trim($stdout);
|
|
}
|
|
|
|
public function getSourceControlPath() {
|
|
return idx($this->getSVNInfo('/'), 'URL');
|
|
}
|
|
|
|
public function getSourceControlBaseRevision() {
|
|
$info = $this->getSVNInfo('/');
|
|
return $info['URL'].'@'.$this->getSVNBaseRevisionNumber();
|
|
}
|
|
|
|
public function getCanonicalRevisionName($string) {
|
|
throw new ArcanistCapabilityNotSupportedException($this);
|
|
}
|
|
|
|
public function getSVNBaseRevisionNumber() {
|
|
if ($this->svnBaseRevisionNumber) {
|
|
return $this->svnBaseRevisionNumber;
|
|
}
|
|
$info = $this->getSVNInfo('/');
|
|
return $info['Revision'];
|
|
}
|
|
|
|
public function overrideSVNBaseRevisionNumber($effective_base_revision) {
|
|
$this->svnBaseRevisionNumber = $effective_base_revision;
|
|
return $this;
|
|
}
|
|
|
|
public function getBranchName() {
|
|
return 'svn';
|
|
}
|
|
|
|
public function buildInfoFuture($path) {
|
|
if ($path == '/') {
|
|
// When the root of a working copy is referenced by a symlink and you
|
|
// execute 'svn info' on that symlink, svn fails. This is a longstanding
|
|
// bug in svn:
|
|
//
|
|
// See http://subversion.tigris.org/issues/show_bug.cgi?id=2305
|
|
//
|
|
// To reproduce, do:
|
|
//
|
|
// $ ln -s working_copy working_link
|
|
// $ svn info working_copy # ok
|
|
// $ svn info working_link # fails
|
|
//
|
|
// Work around this by cd-ing into the directory before executing
|
|
// 'svn info'.
|
|
return $this->buildLocalFuture(array('info .'));
|
|
} else {
|
|
// Note: here and elsewhere we need to append "@" to the path because if
|
|
// a file has a literal "@" in it, everything after that will be
|
|
// interpreted as a revision. By appending "@" with no argument, SVN
|
|
// parses it properly.
|
|
return $this->buildLocalFuture(array('info %s@', $this->getPath($path)));
|
|
}
|
|
}
|
|
|
|
public function buildDiffFuture($path) {
|
|
// The "--depth empty" flag prevents us from picking up changes in
|
|
// children when we run 'diff' against a directory. Specifically, when a
|
|
// user has added or modified some directory "example/", we want to return
|
|
// ONLY changes to that directory when given it as a path. If we run
|
|
// without "--depth empty", svn will give us changes to the directory
|
|
// itself (such as property changes) and also give us changes to any
|
|
// files within the directory (basically, implicit recursion). We don't
|
|
// want that, so prevent recursive diffing.
|
|
$root = phutil_get_library_root('arcanist');
|
|
|
|
if (phutil_is_windows()) {
|
|
// TODO: Provide a binary_safe_diff script for Windows.
|
|
// TODO: Provide a diff command which can take lines of context somehow.
|
|
return $this->buildLocalFuture(
|
|
array(
|
|
'diff --depth empty %s',
|
|
$path,
|
|
));
|
|
} else {
|
|
$diff_bin = $root.'/../scripts/repository/binary_safe_diff.sh';
|
|
$diff_cmd = Filesystem::resolvePath($diff_bin);
|
|
return $this->buildLocalFuture(
|
|
array(
|
|
'diff --depth empty --diff-cmd %s -x -U%d %s',
|
|
$diff_cmd,
|
|
$this->getDiffLinesOfContext(),
|
|
$path,
|
|
));
|
|
}
|
|
}
|
|
|
|
public function primeSVNInfoResult($path, $result) {
|
|
$this->svnInfoRaw[$path] = $result;
|
|
return $this;
|
|
}
|
|
|
|
public function primeSVNDiffResult($path, $result) {
|
|
$this->svnDiffRaw[$path] = $result;
|
|
return $this;
|
|
}
|
|
|
|
public function getSVNInfo($path) {
|
|
|
|
if (empty($this->svnInfo[$path])) {
|
|
|
|
if (empty($this->svnInfoRaw[$path])) {
|
|
$this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve();
|
|
}
|
|
|
|
list($err, $stdout) = $this->svnInfoRaw[$path];
|
|
if ($err) {
|
|
throw new Exception(
|
|
"Error #{$err} executing svn info against '{$path}'.");
|
|
}
|
|
|
|
// TODO: Hack for Windows.
|
|
$stdout = str_replace("\r\n", "\n", $stdout);
|
|
|
|
$patterns = array(
|
|
'/^(URL): (\S+)$/m',
|
|
'/^(Revision): (\d+)$/m',
|
|
'/^(Last Changed Author): (\S+)$/m',
|
|
'/^(Last Changed Rev): (\d+)$/m',
|
|
'/^(Last Changed Date): (.+) \(.+\)$/m',
|
|
'/^(Copied From URL): (\S+)$/m',
|
|
'/^(Copied From Rev): (\d+)$/m',
|
|
'/^(Repository UUID): (\S+)$/m',
|
|
'/^(Node Kind): (\S+)$/m',
|
|
);
|
|
|
|
$result = array();
|
|
foreach ($patterns as $pattern) {
|
|
$matches = null;
|
|
if (preg_match($pattern, $stdout, $matches)) {
|
|
$result[$matches[1]] = $matches[2];
|
|
}
|
|
}
|
|
|
|
if (isset($result['Last Changed Date'])) {
|
|
$result['Last Changed Date'] = strtotime($result['Last Changed Date']);
|
|
}
|
|
|
|
if (empty($result)) {
|
|
throw new Exception('Unable to parse SVN info.');
|
|
}
|
|
|
|
$this->svnInfo[$path] = $result;
|
|
}
|
|
|
|
return $this->svnInfo[$path];
|
|
}
|
|
|
|
|
|
public function getRawDiffText($path) {
|
|
$status = $this->getSVNStatus();
|
|
if (!isset($status[$path])) {
|
|
return null;
|
|
}
|
|
|
|
$status = $status[$path];
|
|
|
|
// Build meaningful diff text for "svn copy" operations.
|
|
if ($status & ArcanistRepositoryAPI::FLAG_ADDED) {
|
|
$info = $this->getSVNInfo($path);
|
|
if (!empty($info['Copied From URL'])) {
|
|
return $this->buildSyntheticAdditionDiff(
|
|
$path,
|
|
$info['Copied From URL'],
|
|
$info['Copied From Rev']);
|
|
}
|
|
}
|
|
|
|
// If we run "diff" on a binary file which doesn't have the "svn:mime-type"
|
|
// of "application/octet-stream", `diff' will explode in a rain of
|
|
// unhelpful hellfire as it tries to build a textual diff of the two
|
|
// files. We just fix this inline since it's pretty unambiguous.
|
|
// TODO: Move this to configuration?
|
|
$matches = null;
|
|
if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) {
|
|
$mime = $this->getSVNProperty($path, 'svn:mime-type');
|
|
if ($mime != 'application/octet-stream') {
|
|
execx(
|
|
'svn propset svn:mime-type application/octet-stream %s',
|
|
$this->getPath($path));
|
|
}
|
|
}
|
|
|
|
if (empty($this->svnDiffRaw[$path])) {
|
|
$this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve();
|
|
}
|
|
|
|
list($err, $stdout, $stderr) = $this->svnDiffRaw[$path];
|
|
|
|
// Note: GNU Diff returns 2 when SVN hands it binary files to diff and they
|
|
// differ. This is not an error; it is documented behavior. But SVN isn't
|
|
// happy about it. SVN will exit with code 1 and return the string below.
|
|
if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") {
|
|
throw new Exception(
|
|
"svn diff returned unexpected error code: $err\n".
|
|
"stdout: $stdout\n".
|
|
"stderr: $stderr");
|
|
}
|
|
|
|
if ($err == 0 && empty($stdout)) {
|
|
// If there are no changes, 'diff' exits with no output, but that means
|
|
// we can not distinguish between empty and unmodified files. Build a
|
|
// synthetic "diff" without any changes in it.
|
|
return $this->buildSyntheticUnchangedDiff($path);
|
|
}
|
|
|
|
return $stdout;
|
|
}
|
|
|
|
protected function buildSyntheticAdditionDiff($path, $source, $rev) {
|
|
$type = $this->getSVNProperty($path, 'svn:mime-type');
|
|
if ($type == 'application/octet-stream') {
|
|
return <<<EODIFF
|
|
Index: {$path}
|
|
===================================================================
|
|
Cannot display: file marked as a binary type.
|
|
svn:mime-type = application/octet-stream
|
|
|
|
EODIFF;
|
|
}
|
|
|
|
if (is_dir($this->getPath($path))) {
|
|
return null;
|
|
}
|
|
|
|
$data = Filesystem::readFile($this->getPath($path));
|
|
list($orig) = execx('svn cat %s@%s', $source, $rev);
|
|
|
|
$src = new TempFile();
|
|
$dst = new TempFile();
|
|
Filesystem::writeFile($src, $orig);
|
|
Filesystem::writeFile($dst, $data);
|
|
|
|
list($err, $diff) = exec_manual(
|
|
'diff -L a/%s -L b/%s -U%d %s %s',
|
|
str_replace($this->getSourceControlPath().'/', '', $source),
|
|
$path,
|
|
$this->getDiffLinesOfContext(),
|
|
$src,
|
|
$dst);
|
|
|
|
if ($err == 1) { // 1 means there are differences.
|
|
return <<<EODIFF
|
|
Index: {$path}
|
|
===================================================================
|
|
{$diff}
|
|
|
|
EODIFF;
|
|
} else {
|
|
return $this->buildSyntheticUnchangedDiff($path);
|
|
}
|
|
}
|
|
|
|
protected function buildSyntheticUnchangedDiff($path) {
|
|
$full_path = $this->getPath($path);
|
|
if (is_dir($full_path)) {
|
|
return null;
|
|
}
|
|
|
|
if (!file_exists($full_path)) {
|
|
return null;
|
|
}
|
|
|
|
$data = Filesystem::readFile($full_path);
|
|
$lines = explode("\n", $data);
|
|
$len = count($lines);
|
|
foreach ($lines as $key => $line) {
|
|
$lines[$key] = ' '.$line;
|
|
}
|
|
$lines = implode("\n", $lines);
|
|
return <<<EODIFF
|
|
Index: {$path}
|
|
===================================================================
|
|
--- {$path} (synthetic)
|
|
+++ {$path} (synthetic)
|
|
@@ -1,{$len} +1,{$len} @@
|
|
{$lines}
|
|
|
|
EODIFF;
|
|
}
|
|
|
|
public function getAllFiles() {
|
|
// TODO: Handle paths with newlines.
|
|
$future = $this->buildLocalFuture(array('list -R'));
|
|
return new PhutilCallbackFilterIterator(
|
|
new LinesOfALargeExecFuture($future),
|
|
array($this, 'filterFiles'));
|
|
}
|
|
|
|
public function getChangedFiles($since_commit) {
|
|
// TODO: Handle paths with newlines.
|
|
list($stdout) = $this->execxLocal(
|
|
'--xml diff --revision %s:HEAD --summarize',
|
|
$since_commit);
|
|
$xml = new SimpleXMLElement($stdout);
|
|
|
|
$return = array();
|
|
foreach ($xml->paths[0]->path as $path) {
|
|
$return[(string)$path] = $this->parseSVNStatus($path['item']);
|
|
}
|
|
return $return;
|
|
}
|
|
|
|
public function filterFiles($path) {
|
|
// NOTE: SVN uses '/' also on Windows.
|
|
if ($path == '' || substr($path, -1) == '/') {
|
|
return null;
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
public function getBlame($path) {
|
|
$blame = array();
|
|
|
|
list($stdout) = $this->execxLocal('blame %s', $path);
|
|
|
|
$stdout = trim($stdout);
|
|
if (!strlen($stdout)) {
|
|
// Empty file.
|
|
return $blame;
|
|
}
|
|
|
|
foreach (explode("\n", $stdout) as $line) {
|
|
$m = array();
|
|
if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) {
|
|
throw new Exception("Bad blame? `{$line}'");
|
|
}
|
|
$revision = $m[1];
|
|
$author = $m[2];
|
|
$blame[] = array($author, $revision);
|
|
}
|
|
|
|
return $blame;
|
|
}
|
|
|
|
public function getOriginalFileData($path) {
|
|
// SVN issues warnings for nonexistent paths, directories, etc., but still
|
|
// returns no error code. However, for new paths in the working copy it
|
|
// fails. Assume that failure means the original file does not exist.
|
|
list($err, $stdout) = $this->execManualLocal('cat %s@', $path);
|
|
if ($err) {
|
|
return null;
|
|
}
|
|
return $stdout;
|
|
}
|
|
|
|
public function getCurrentFileData($path) {
|
|
$full_path = $this->getPath($path);
|
|
if (Filesystem::pathExists($full_path)) {
|
|
return Filesystem::readFile($full_path);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function getRepositorySVNUUID() {
|
|
$info = $this->getSVNInfo('/');
|
|
return $info['Repository UUID'];
|
|
}
|
|
|
|
public function getLocalCommitInformation() {
|
|
return null;
|
|
}
|
|
|
|
public function isHistoryDefaultImmutable() {
|
|
return true;
|
|
}
|
|
|
|
public function supportsAmend() {
|
|
return false;
|
|
}
|
|
|
|
public function supportsCommitRanges() {
|
|
return false;
|
|
}
|
|
|
|
public function supportsLocalCommits() {
|
|
return false;
|
|
}
|
|
|
|
public function hasLocalCommit($commit) {
|
|
return false;
|
|
}
|
|
|
|
public function getWorkingCopyRevision() {
|
|
return $this->getSourceControlBaseRevision();
|
|
}
|
|
|
|
public function supportsLocalBranchMerge() {
|
|
return false;
|
|
}
|
|
|
|
public function getFinalizedRevisionMessage() {
|
|
// In other VCSes we give push instructions here, but it never makes sense
|
|
// in SVN.
|
|
return "Done.";
|
|
}
|
|
|
|
public function loadWorkingCopyDifferentialRevisions(
|
|
ConduitClient $conduit,
|
|
array $query) {
|
|
|
|
// We don't have much to go on in SVN, look for revisions that came from
|
|
// this directory and belong to the same project.
|
|
|
|
$project = $this->getWorkingCopyIdentity()->getProjectID();
|
|
if (!$project) {
|
|
return array();
|
|
}
|
|
|
|
$results = $conduit->callMethodSynchronous(
|
|
'differential.query',
|
|
$query + array(
|
|
'arcanistProjects' => array($project),
|
|
));
|
|
|
|
foreach ($results as $key => $result) {
|
|
if ($result['sourcePath'] != $this->getPath()) {
|
|
unset($results[$key]);
|
|
}
|
|
}
|
|
|
|
foreach ($results as $key => $result) {
|
|
$results[$key]['why'] =
|
|
"Matching arcanist project name and working copy directory path.";
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
public function updateWorkingCopy() {
|
|
$this->execxLocal('up');
|
|
}
|
|
|
|
}
|