1
0
Fork 0
mirror of https://we.phorge.it/source/arcanist.git synced 2024-12-02 03:32:41 +01:00
phorge-arcanist/src/repository/api/ArcanistSubversionAPI.php

671 lines
19 KiB
PHP
Raw Normal View History

2011-01-10 00:22:25 +01:00
<?php
/**
* Interfaces with Subversion working copies.
*
* @group workingcopy
*/
final class ArcanistSubversionAPI extends ArcanistRepositoryAPI {
2011-01-10 00:22:25 +01:00
protected $svnStatus;
protected $svnBaseRevisions;
protected $svnInfo = array();
protected $svnInfoRaw = array();
protected $svnDiffRaw = array();
private $svnBaseRevisionNumber;
private $statusPaths = array();
2011-01-10 00:22:25 +01:00
public function getSourceControlSystemName() {
return 'svn';
}
public function getMetadataPath() {
[Arcanist] fix scratch dir for svn >= 1.7 Summary: svn 1.7 got rid of the per-direcotry .svn dirs in favor of a single .svn directory under the root of the working copy. Unfortunately, we relied on ther being a .svn directory at the same level as .arcconfig, which may or may not be at the root of the working copy. We now walk up the directory tree until we find a .svn directory that we can use for scratch files. Test Plan: [16:27:52 Mon Jun 25 2012] dschleimer@dev4022.snc6 ~/data/admin/facebook/scripts/db db 21298 $ ls -la .arcconfig .svn ../../../.svn/arc/config ls: .svn: No such file or directory ls: ../../../.svn/arc/config: No such file or directory -rw-r--r-- 1 dschleimer users 239 Jun 25 16:15 .arcconfig [16:27:54 Mon Jun 25 2012] dschleimer@dev4022.snc6 ~/data/admin/facebook/scripts/db db 21298 $ ~/devtools/arcanist/bin/arc set-config --local foo bar Set key 'foo' = 'bar' in local config. [16:29:40 Mon Jun 25 2012] dschleimer@dev4022.snc6 ~/data/admin/facebook/scripts/db db 21298 $ ls -la .arcconfig .svn ../../../.svn/arc/config ls: .svn: No such file or directory -rw-r--r-- 1 dschleimer users 239 Jun 25 16:15 .arcconfig -rw-r--r-- 1 dschleimer users 20 Jun 25 16:29 ../../../.svn/arc/config [16:29:43 Mon Jun 25 2012] dschleimer@dev4022.snc6 ~/data/admin/facebook/scripts/db db 21298 $ cat ../../../.svn/arc/config { "foo" : "bar" } [16:29:48 Mon Jun 25 2012] dschleimer@dev4022.snc6 ~/data/admin/facebook/scripts/db db 21299 $ ~/devtools/arcanist/bin/arc get-config foo (global) foo = (local) foo = bar Reviewers: epriestley Reviewed By: epriestley CC: aran, Korvin Maniphest Tasks: T1411 Differential Revision: https://secure.phabricator.com/D2856
2012-06-26 02:13:29 +02:00
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;
}
Refactor getWorkingCopyStatus() into getUncommittedStatus() and getCommitRangeStatus() Summary: See discussion in D4049. The getWorkingCopyStatus() method gets called from requireCleanWorkingCopy() in a lot of places, which triggers resolution of the base of the commit range. This is unnecessary; we do not need to examine the base commit in order to determine whether the working copy is dirty or not. This causes problems, in D4049 and elsewhere (we currently have a lot of fluff calls to setDefaultBaseCommit() in workflows which need to call requireCleanWorkingCopy() but do not ever use commit ranges, such as `arc patch`). This is mostly an artifact of SVN, where the "commit range" and "uncommitted stuff in the working copy" are always the same. - Split the method into two status methods: getUncommittedStatus() (uncommitted stuff in the working copy, required by requireCleanWorkingCopy()) and getCommitRangeStatus() (committed stuff in the commit range). - Lift caching out of the implementations into the base class. - Dirty the cache after we commit. This doesn't do anything useful on its own and creates one caching problem (`commitRangeStatusCache` is not invalidated when the commit range changes because of `setBaseCommit()` or similar) but I wanted to break things apart here. I won't land it until there's a more complete picture. This creates a minor performance regression in git and hg (we run less stuff in parallel than previously) but all the commands should be disk-bound anyway and the regression should be minor. It prevents a larger regression in `hg` in D4049, and lets us do less work to arrive at common error states (dirty working copy). We can examine perf at the end of this change sequence. Test Plan: Ran unit tests, various `arc` commands. Reviewers: vrana Reviewed By: vrana CC: aran Differential Revision: https://secure.phabricator.com/D4095
2012-12-17 21:53:28 +01:00
protected function buildCommitRangeStatus() {
// In SVN, the commit range is always "uncommitted changes", so these
// statuses are equivalent.
return $this->getUncommittedStatus();
}
protected function buildUncommittedStatus() {
2011-01-10 00:22:25 +01:00
return $this->getSVNStatus();
}
public function getSVNBaseRevisions() {
if ($this->svnBaseRevisions === null) {
$this->getSVNStatus();
}
return $this->svnBaseRevisions;
}
public function limitStatusToPaths(array $paths) {
$this->statusPaths = $paths;
return $this;
}
2011-01-10 00:22:25 +01:00
public function getSVNStatus($with_externals = false) {
if ($this->svnStatus === null) {
if ($this->statusPaths) {
list($status) = $this->execxLocal(
'--xml status %Ls',
$this->statusPaths);
} else {
list($status) = $this->execxLocal('--xml status');
}
2011-01-10 00:22:25 +01:00
$xml = new SimpleXMLElement($status);
$externals = array();
$files = array();
foreach ($xml->target as $target) {
$this->svnBaseRevisions = array();
foreach ($target->entry as $entry) {
$path = (string)$entry['path'];
// On Windows, we get paths with backslash directory separators here.
// Normalize them to the format everything else expects and generates.
if (phutil_is_windows()) {
$path = str_replace(DIRECTORY_SEPARATOR, '/', $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}'.");
}
2011-01-10 00:22:25 +01:00
$mask |= $this->parseSVNStatus($item);
if ($item == 'external') {
$externals[] = $path;
}
2011-01-10 00:22:25 +01:00
// This is new in or around Subversion 1.6.
$tree_conflicts = ($entry->{'wc-status'}[0]['tree-conflicted']);
if ((string)$tree_conflicts) {
$mask |= self::FLAG_CONFLICT;
}
2011-03-20 23:06:55 +01:00
$files[$path] = $mask;
}
2011-01-10 00:22:25 +01:00
}
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 'none':
// We can get 'none' for property changes on a directory.
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) {
$add = array_filter($paths, 'Filesystem::pathExists');
if ($add) {
$this->execxLocal(
'add -- %Ls',
$add);
}
if ($add != $paths) {
$this->execxLocal(
'delete -- %Ls',
array_diff($paths, $add));
}
}
2011-01-10 00:22:25 +01:00
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;
2011-01-10 00:22:25 +01:00
}
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)));
}
2011-01-10 00:22:25 +01:00
}
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,
));
}
2011-01-10 00:22:25 +01:00
}
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);
2011-01-10 00:22:25 +01:00
$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',
2011-04-06 07:27:32 +02:00
'/^(Repository UUID): (\S+)$/m',
'/^(Node Kind): (\S+)$/m',
2011-01-10 00:22:25 +01:00
);
$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;
}
2011-01-10 00:22:25 +01:00
$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) {
$url = '';
$match = null;
if (preg_match('/(.*)@(.*)/', $since_commit, $match)) {
list(, $url, $since_commit) = $match;
}
// TODO: Handle paths with newlines.
list($stdout) = $this->execxLocal(
'--xml diff --revision %s:HEAD --summarize %s',
$since_commit,
$url);
$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;
}
2011-01-10 00:22:25 +01:00
public function getBlame($path) {
$blame = array();
list($stdout) = $this->execxLocal('blame %s', $path);
2011-01-10 00:22:25 +01:00
$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;
}
2011-01-10 00:22:25 +01:00
2011-04-06 05:12:37 +02:00
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');
}
public static function escapeFileNamesForSVN(array $files) {
foreach ($files as $k => $file) {
$files[$k] = self::escapeFileNameForSVN($file);
}
return $files;
}
public static function escapeFileNameForSVN($file) {
// SVN interprets "x@1" as meaning "file x at revision 1", which is not
// intended for files named "sprite@2x.png" or similar. For files with an
// "@" in their names, escape them by adding "@" at the end, which SVN
// interprets as "at the working copy revision". There is a special case
// where ".@" means "fail with an error" instead of ". at the working copy
// revision", so avoid escaping "." into ".@".
if (strpos($file, '@') !== false) {
$file = $file.'@';
}
return $file;
}
2011-01-10 00:22:25 +01:00
}