/src/repository/api/ArcanistSubversionAPI.php
PHP | 695 lines | 529 code | 95 blank | 71 comment | 66 complexity | 075030bd26f56e1cd0ee89f3a1484d72 MD5 | raw file
Possible License(s): Apache-2.0
- <?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;
- private $statusPaths = array();
- 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 limitStatusToPaths(array $paths) {
- $this->statusPaths = $paths;
- return $this;
- }
- 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');
- }
- $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}'.");
- }
- $mask |= $this->parseSVNStatus($item);
- if ($item == 'external') {
- $externals[] = $path;
- }
- // 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;
- }
- $files[$path] = $mask;
- }
- }
- foreach ($files as $path => $mask) {
- foreach ($externals as $external) {
- if (!strncmp($path.'/', $external.'/', strlen($external) + 1)) {
- $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));
- }
- $this->svnStatus = null;
- }
- 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() {
- $info = $this->getSVNInfo('/');
- $repo_root = idx($info, 'Repository Root');
- $repo_root_length = strlen($repo_root);
- $url = idx($info, 'URL');
- if (substr($url, 0, $repo_root_length) == $repo_root) {
- return substr($url, $repo_root_length);
- }
- return 'svn';
- }
- public function getRemoteURI() {
- return idx($this->getSVNInfo('/'), 'Repository Root');
- }
- 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 Root): (\S+)$/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)) {
- // Check if the file is deleted first; SVN will complain if we try to
- // get properties of a deleted file.
- if ($status & ArcanistRepositoryAPI::FLAG_DELETED) {
- return <<<EODIFF
- Index: {$path}
- ===================================================================
- Cannot display: file marked as a binary type.
- svn:mime-type = application/octet-stream
- EODIFF;
- }
- $mime = $this->getSVNProperty($path, 'svn:mime-type');
- if ($mime != 'application/octet-stream') {
- execx(
- 'svn propset svn:mime-type application/octet-stream %s',
- self::escapeFileNameForSVN($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) {
- $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;
- }
- 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 getRepositoryUUID() {
- $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;
- }
- }