PageRenderTime 62ms CodeModel.GetById 30ms RepoModel.GetById 1ms app.codeStats 0ms

/src/repository/api/ArcanistSubversionAPI.php

https://github.com/nate-opti/arcanist
PHP | 695 lines | 529 code | 95 blank | 71 comment | 66 complexity | 075030bd26f56e1cd0ee89f3a1484d72 MD5 | raw file
Possible License(s): Apache-2.0
  1. <?php
  2. /**
  3. * Interfaces with Subversion working copies.
  4. *
  5. * @group workingcopy
  6. */
  7. final class ArcanistSubversionAPI extends ArcanistRepositoryAPI {
  8. protected $svnStatus;
  9. protected $svnBaseRevisions;
  10. protected $svnInfo = array();
  11. protected $svnInfoRaw = array();
  12. protected $svnDiffRaw = array();
  13. private $svnBaseRevisionNumber;
  14. private $statusPaths = array();
  15. public function getSourceControlSystemName() {
  16. return 'svn';
  17. }
  18. public function getMetadataPath() {
  19. static $svn_dir = null;
  20. if ($svn_dir === null) {
  21. // from svn 1.7, subversion keeps a single .svn directly under
  22. // the working copy root. However, we allow .arcconfigs that
  23. // aren't at the working copy root.
  24. foreach (Filesystem::walkToRoot($this->getPath()) as $parent) {
  25. $possible_svn_dir = Filesystem::resolvePath('.svn', $parent);
  26. if (Filesystem::pathExists($possible_svn_dir)) {
  27. $svn_dir = $possible_svn_dir;
  28. break;
  29. }
  30. }
  31. }
  32. return $svn_dir;
  33. }
  34. protected function buildLocalFuture(array $argv) {
  35. $argv[0] = 'svn '.$argv[0];
  36. $future = newv('ExecFuture', $argv);
  37. $future->setCWD($this->getPath());
  38. return $future;
  39. }
  40. protected function buildCommitRangeStatus() {
  41. // In SVN, the commit range is always "uncommitted changes", so these
  42. // statuses are equivalent.
  43. return $this->getUncommittedStatus();
  44. }
  45. protected function buildUncommittedStatus() {
  46. return $this->getSVNStatus();
  47. }
  48. public function getSVNBaseRevisions() {
  49. if ($this->svnBaseRevisions === null) {
  50. $this->getSVNStatus();
  51. }
  52. return $this->svnBaseRevisions;
  53. }
  54. public function limitStatusToPaths(array $paths) {
  55. $this->statusPaths = $paths;
  56. return $this;
  57. }
  58. public function getSVNStatus($with_externals = false) {
  59. if ($this->svnStatus === null) {
  60. if ($this->statusPaths) {
  61. list($status) = $this->execxLocal(
  62. '--xml status %Ls',
  63. $this->statusPaths);
  64. } else {
  65. list($status) = $this->execxLocal('--xml status');
  66. }
  67. $xml = new SimpleXMLElement($status);
  68. $externals = array();
  69. $files = array();
  70. foreach ($xml->target as $target) {
  71. $this->svnBaseRevisions = array();
  72. foreach ($target->entry as $entry) {
  73. $path = (string)$entry['path'];
  74. // On Windows, we get paths with backslash directory separators here.
  75. // Normalize them to the format everything else expects and generates.
  76. if (phutil_is_windows()) {
  77. $path = str_replace(DIRECTORY_SEPARATOR, '/', $path);
  78. }
  79. $mask = 0;
  80. $props = (string)($entry->{'wc-status'}[0]['props']);
  81. $item = (string)($entry->{'wc-status'}[0]['item']);
  82. $base = (string)($entry->{'wc-status'}[0]['revision']);
  83. $this->svnBaseRevisions[$path] = $base;
  84. switch ($props) {
  85. case 'none':
  86. case 'normal':
  87. break;
  88. case 'modified':
  89. $mask |= self::FLAG_MODIFIED;
  90. break;
  91. default:
  92. throw new Exception("Unrecognized property status '{$props}'.");
  93. }
  94. $mask |= $this->parseSVNStatus($item);
  95. if ($item == 'external') {
  96. $externals[] = $path;
  97. }
  98. // This is new in or around Subversion 1.6.
  99. $tree_conflicts = ($entry->{'wc-status'}[0]['tree-conflicted']);
  100. if ((string)$tree_conflicts) {
  101. $mask |= self::FLAG_CONFLICT;
  102. }
  103. $files[$path] = $mask;
  104. }
  105. }
  106. foreach ($files as $path => $mask) {
  107. foreach ($externals as $external) {
  108. if (!strncmp($path.'/', $external.'/', strlen($external) + 1)) {
  109. $files[$path] |= self::FLAG_EXTERNALS;
  110. }
  111. }
  112. }
  113. $this->svnStatus = $files;
  114. }
  115. $status = $this->svnStatus;
  116. if (!$with_externals) {
  117. foreach ($status as $path => $mask) {
  118. if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) {
  119. unset($status[$path]);
  120. }
  121. }
  122. }
  123. return $status;
  124. }
  125. private function parseSVNStatus($item) {
  126. switch ($item) {
  127. case 'none':
  128. // We can get 'none' for property changes on a directory.
  129. case 'normal':
  130. return 0;
  131. case 'external':
  132. return self::FLAG_EXTERNALS;
  133. case 'unversioned':
  134. return self::FLAG_UNTRACKED;
  135. case 'obstructed':
  136. return self::FLAG_OBSTRUCTED;
  137. case 'missing':
  138. return self::FLAG_MISSING;
  139. case 'added':
  140. return self::FLAG_ADDED;
  141. case 'replaced':
  142. // This is the result of "svn rm"-ing a file, putting another one
  143. // in place of it, and then "svn add"-ing the new file. Just treat
  144. // this as equivalent to "modified".
  145. return self::FLAG_MODIFIED;
  146. case 'modified':
  147. return self::FLAG_MODIFIED;
  148. case 'deleted':
  149. return self::FLAG_DELETED;
  150. case 'conflicted':
  151. return self::FLAG_CONFLICT;
  152. case 'incomplete':
  153. return self::FLAG_INCOMPLETE;
  154. default:
  155. throw new Exception("Unrecognized item status '{$item}'.");
  156. }
  157. }
  158. public function addToCommit(array $paths) {
  159. $add = array_filter($paths, 'Filesystem::pathExists');
  160. if ($add) {
  161. $this->execxLocal(
  162. 'add -- %Ls',
  163. $add);
  164. }
  165. if ($add != $paths) {
  166. $this->execxLocal(
  167. 'delete -- %Ls',
  168. array_diff($paths, $add));
  169. }
  170. $this->svnStatus = null;
  171. }
  172. public function getSVNProperty($path, $property) {
  173. list($stdout) = execx(
  174. 'svn propget %s %s@',
  175. $property,
  176. $this->getPath($path));
  177. return trim($stdout);
  178. }
  179. public function getSourceControlPath() {
  180. return idx($this->getSVNInfo('/'), 'URL');
  181. }
  182. public function getSourceControlBaseRevision() {
  183. $info = $this->getSVNInfo('/');
  184. return $info['URL'].'@'.$this->getSVNBaseRevisionNumber();
  185. }
  186. public function getCanonicalRevisionName($string) {
  187. throw new ArcanistCapabilityNotSupportedException($this);
  188. }
  189. public function getSVNBaseRevisionNumber() {
  190. if ($this->svnBaseRevisionNumber) {
  191. return $this->svnBaseRevisionNumber;
  192. }
  193. $info = $this->getSVNInfo('/');
  194. return $info['Revision'];
  195. }
  196. public function overrideSVNBaseRevisionNumber($effective_base_revision) {
  197. $this->svnBaseRevisionNumber = $effective_base_revision;
  198. return $this;
  199. }
  200. public function getBranchName() {
  201. $info = $this->getSVNInfo('/');
  202. $repo_root = idx($info, 'Repository Root');
  203. $repo_root_length = strlen($repo_root);
  204. $url = idx($info, 'URL');
  205. if (substr($url, 0, $repo_root_length) == $repo_root) {
  206. return substr($url, $repo_root_length);
  207. }
  208. return 'svn';
  209. }
  210. public function getRemoteURI() {
  211. return idx($this->getSVNInfo('/'), 'Repository Root');
  212. }
  213. public function buildInfoFuture($path) {
  214. if ($path == '/') {
  215. // When the root of a working copy is referenced by a symlink and you
  216. // execute 'svn info' on that symlink, svn fails. This is a longstanding
  217. // bug in svn:
  218. //
  219. // See http://subversion.tigris.org/issues/show_bug.cgi?id=2305
  220. //
  221. // To reproduce, do:
  222. //
  223. // $ ln -s working_copy working_link
  224. // $ svn info working_copy # ok
  225. // $ svn info working_link # fails
  226. //
  227. // Work around this by cd-ing into the directory before executing
  228. // 'svn info'.
  229. return $this->buildLocalFuture(array('info .'));
  230. } else {
  231. // Note: here and elsewhere we need to append "@" to the path because if
  232. // a file has a literal "@" in it, everything after that will be
  233. // interpreted as a revision. By appending "@" with no argument, SVN
  234. // parses it properly.
  235. return $this->buildLocalFuture(array('info %s@', $this->getPath($path)));
  236. }
  237. }
  238. public function buildDiffFuture($path) {
  239. // The "--depth empty" flag prevents us from picking up changes in
  240. // children when we run 'diff' against a directory. Specifically, when a
  241. // user has added or modified some directory "example/", we want to return
  242. // ONLY changes to that directory when given it as a path. If we run
  243. // without "--depth empty", svn will give us changes to the directory
  244. // itself (such as property changes) and also give us changes to any
  245. // files within the directory (basically, implicit recursion). We don't
  246. // want that, so prevent recursive diffing.
  247. $root = phutil_get_library_root('arcanist');
  248. if (phutil_is_windows()) {
  249. // TODO: Provide a binary_safe_diff script for Windows.
  250. // TODO: Provide a diff command which can take lines of context somehow.
  251. return $this->buildLocalFuture(
  252. array(
  253. 'diff --depth empty %s',
  254. $path,
  255. ));
  256. } else {
  257. $diff_bin = $root.'/../scripts/repository/binary_safe_diff.sh';
  258. $diff_cmd = Filesystem::resolvePath($diff_bin);
  259. return $this->buildLocalFuture(
  260. array(
  261. 'diff --depth empty --diff-cmd %s -x -U%d %s',
  262. $diff_cmd,
  263. $this->getDiffLinesOfContext(),
  264. $path,
  265. ));
  266. }
  267. }
  268. public function primeSVNInfoResult($path, $result) {
  269. $this->svnInfoRaw[$path] = $result;
  270. return $this;
  271. }
  272. public function primeSVNDiffResult($path, $result) {
  273. $this->svnDiffRaw[$path] = $result;
  274. return $this;
  275. }
  276. public function getSVNInfo($path) {
  277. if (empty($this->svnInfo[$path])) {
  278. if (empty($this->svnInfoRaw[$path])) {
  279. $this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve();
  280. }
  281. list($err, $stdout) = $this->svnInfoRaw[$path];
  282. if ($err) {
  283. throw new Exception(
  284. "Error #{$err} executing svn info against '{$path}'.");
  285. }
  286. // TODO: Hack for Windows.
  287. $stdout = str_replace("\r\n", "\n", $stdout);
  288. $patterns = array(
  289. '/^(URL): (\S+)$/m',
  290. '/^(Revision): (\d+)$/m',
  291. '/^(Last Changed Author): (\S+)$/m',
  292. '/^(Last Changed Rev): (\d+)$/m',
  293. '/^(Last Changed Date): (.+) \(.+\)$/m',
  294. '/^(Copied From URL): (\S+)$/m',
  295. '/^(Copied From Rev): (\d+)$/m',
  296. '/^(Repository Root): (\S+)$/m',
  297. '/^(Repository UUID): (\S+)$/m',
  298. '/^(Node Kind): (\S+)$/m',
  299. );
  300. $result = array();
  301. foreach ($patterns as $pattern) {
  302. $matches = null;
  303. if (preg_match($pattern, $stdout, $matches)) {
  304. $result[$matches[1]] = $matches[2];
  305. }
  306. }
  307. if (isset($result['Last Changed Date'])) {
  308. $result['Last Changed Date'] = strtotime($result['Last Changed Date']);
  309. }
  310. if (empty($result)) {
  311. throw new Exception('Unable to parse SVN info.');
  312. }
  313. $this->svnInfo[$path] = $result;
  314. }
  315. return $this->svnInfo[$path];
  316. }
  317. public function getRawDiffText($path) {
  318. $status = $this->getSVNStatus();
  319. if (!isset($status[$path])) {
  320. return null;
  321. }
  322. $status = $status[$path];
  323. // Build meaningful diff text for "svn copy" operations.
  324. if ($status & ArcanistRepositoryAPI::FLAG_ADDED) {
  325. $info = $this->getSVNInfo($path);
  326. if (!empty($info['Copied From URL'])) {
  327. return $this->buildSyntheticAdditionDiff(
  328. $path,
  329. $info['Copied From URL'],
  330. $info['Copied From Rev']);
  331. }
  332. }
  333. // If we run "diff" on a binary file which doesn't have the "svn:mime-type"
  334. // of "application/octet-stream", `diff' will explode in a rain of
  335. // unhelpful hellfire as it tries to build a textual diff of the two
  336. // files. We just fix this inline since it's pretty unambiguous.
  337. // TODO: Move this to configuration?
  338. $matches = null;
  339. if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) {
  340. // Check if the file is deleted first; SVN will complain if we try to
  341. // get properties of a deleted file.
  342. if ($status & ArcanistRepositoryAPI::FLAG_DELETED) {
  343. return <<<EODIFF
  344. Index: {$path}
  345. ===================================================================
  346. Cannot display: file marked as a binary type.
  347. svn:mime-type = application/octet-stream
  348. EODIFF;
  349. }
  350. $mime = $this->getSVNProperty($path, 'svn:mime-type');
  351. if ($mime != 'application/octet-stream') {
  352. execx(
  353. 'svn propset svn:mime-type application/octet-stream %s',
  354. self::escapeFileNameForSVN($this->getPath($path)));
  355. }
  356. }
  357. if (empty($this->svnDiffRaw[$path])) {
  358. $this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve();
  359. }
  360. list($err, $stdout, $stderr) = $this->svnDiffRaw[$path];
  361. // Note: GNU Diff returns 2 when SVN hands it binary files to diff and they
  362. // differ. This is not an error; it is documented behavior. But SVN isn't
  363. // happy about it. SVN will exit with code 1 and return the string below.
  364. if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") {
  365. throw new Exception(
  366. "svn diff returned unexpected error code: $err\n".
  367. "stdout: $stdout\n".
  368. "stderr: $stderr");
  369. }
  370. if ($err == 0 && empty($stdout)) {
  371. // If there are no changes, 'diff' exits with no output, but that means
  372. // we can not distinguish between empty and unmodified files. Build a
  373. // synthetic "diff" without any changes in it.
  374. return $this->buildSyntheticUnchangedDiff($path);
  375. }
  376. return $stdout;
  377. }
  378. protected function buildSyntheticAdditionDiff($path, $source, $rev) {
  379. $type = $this->getSVNProperty($path, 'svn:mime-type');
  380. if ($type == 'application/octet-stream') {
  381. return <<<EODIFF
  382. Index: {$path}
  383. ===================================================================
  384. Cannot display: file marked as a binary type.
  385. svn:mime-type = application/octet-stream
  386. EODIFF;
  387. }
  388. if (is_dir($this->getPath($path))) {
  389. return null;
  390. }
  391. $data = Filesystem::readFile($this->getPath($path));
  392. list($orig) = execx('svn cat %s@%s', $source, $rev);
  393. $src = new TempFile();
  394. $dst = new TempFile();
  395. Filesystem::writeFile($src, $orig);
  396. Filesystem::writeFile($dst, $data);
  397. list($err, $diff) = exec_manual(
  398. 'diff -L a/%s -L b/%s -U%d %s %s',
  399. str_replace($this->getSourceControlPath().'/', '', $source),
  400. $path,
  401. $this->getDiffLinesOfContext(),
  402. $src,
  403. $dst);
  404. if ($err == 1) { // 1 means there are differences.
  405. return <<<EODIFF
  406. Index: {$path}
  407. ===================================================================
  408. {$diff}
  409. EODIFF;
  410. } else {
  411. return $this->buildSyntheticUnchangedDiff($path);
  412. }
  413. }
  414. protected function buildSyntheticUnchangedDiff($path) {
  415. $full_path = $this->getPath($path);
  416. if (is_dir($full_path)) {
  417. return null;
  418. }
  419. if (!file_exists($full_path)) {
  420. return null;
  421. }
  422. $data = Filesystem::readFile($full_path);
  423. $lines = explode("\n", $data);
  424. $len = count($lines);
  425. foreach ($lines as $key => $line) {
  426. $lines[$key] = ' '.$line;
  427. }
  428. $lines = implode("\n", $lines);
  429. return <<<EODIFF
  430. Index: {$path}
  431. ===================================================================
  432. --- {$path} (synthetic)
  433. +++ {$path} (synthetic)
  434. @@ -1,{$len} +1,{$len} @@
  435. {$lines}
  436. EODIFF;
  437. }
  438. public function getAllFiles() {
  439. // TODO: Handle paths with newlines.
  440. $future = $this->buildLocalFuture(array('list -R'));
  441. return new PhutilCallbackFilterIterator(
  442. new LinesOfALargeExecFuture($future),
  443. array($this, 'filterFiles'));
  444. }
  445. public function getChangedFiles($since_commit) {
  446. $url = '';
  447. $match = null;
  448. if (preg_match('/(.*)@(.*)/', $since_commit, $match)) {
  449. list(, $url, $since_commit) = $match;
  450. }
  451. // TODO: Handle paths with newlines.
  452. list($stdout) = $this->execxLocal(
  453. '--xml diff --revision %s:HEAD --summarize %s',
  454. $since_commit,
  455. $url);
  456. $xml = new SimpleXMLElement($stdout);
  457. $return = array();
  458. foreach ($xml->paths[0]->path as $path) {
  459. $return[(string)$path] = $this->parseSVNStatus($path['item']);
  460. }
  461. return $return;
  462. }
  463. public function filterFiles($path) {
  464. // NOTE: SVN uses '/' also on Windows.
  465. if ($path == '' || substr($path, -1) == '/') {
  466. return null;
  467. }
  468. return $path;
  469. }
  470. public function getBlame($path) {
  471. $blame = array();
  472. list($stdout) = $this->execxLocal('blame %s', $path);
  473. $stdout = trim($stdout);
  474. if (!strlen($stdout)) {
  475. // Empty file.
  476. return $blame;
  477. }
  478. foreach (explode("\n", $stdout) as $line) {
  479. $m = array();
  480. if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) {
  481. throw new Exception("Bad blame? `{$line}'");
  482. }
  483. $revision = $m[1];
  484. $author = $m[2];
  485. $blame[] = array($author, $revision);
  486. }
  487. return $blame;
  488. }
  489. public function getOriginalFileData($path) {
  490. // SVN issues warnings for nonexistent paths, directories, etc., but still
  491. // returns no error code. However, for new paths in the working copy it
  492. // fails. Assume that failure means the original file does not exist.
  493. list($err, $stdout) = $this->execManualLocal('cat %s@', $path);
  494. if ($err) {
  495. return null;
  496. }
  497. return $stdout;
  498. }
  499. public function getCurrentFileData($path) {
  500. $full_path = $this->getPath($path);
  501. if (Filesystem::pathExists($full_path)) {
  502. return Filesystem::readFile($full_path);
  503. }
  504. return null;
  505. }
  506. public function getRepositoryUUID() {
  507. $info = $this->getSVNInfo('/');
  508. return $info['Repository UUID'];
  509. }
  510. public function getLocalCommitInformation() {
  511. return null;
  512. }
  513. public function isHistoryDefaultImmutable() {
  514. return true;
  515. }
  516. public function supportsAmend() {
  517. return false;
  518. }
  519. public function supportsCommitRanges() {
  520. return false;
  521. }
  522. public function supportsLocalCommits() {
  523. return false;
  524. }
  525. public function hasLocalCommit($commit) {
  526. return false;
  527. }
  528. public function getWorkingCopyRevision() {
  529. return $this->getSourceControlBaseRevision();
  530. }
  531. public function supportsLocalBranchMerge() {
  532. return false;
  533. }
  534. public function getFinalizedRevisionMessage() {
  535. // In other VCSes we give push instructions here, but it never makes sense
  536. // in SVN.
  537. return 'Done.';
  538. }
  539. public function loadWorkingCopyDifferentialRevisions(
  540. ConduitClient $conduit,
  541. array $query) {
  542. // We don't have much to go on in SVN, look for revisions that came from
  543. // this directory and belong to the same project.
  544. $project = $this->getWorkingCopyIdentity()->getProjectID();
  545. if (!$project) {
  546. return array();
  547. }
  548. $results = $conduit->callMethodSynchronous(
  549. 'differential.query',
  550. $query + array(
  551. 'arcanistProjects' => array($project),
  552. ));
  553. foreach ($results as $key => $result) {
  554. if ($result['sourcePath'] != $this->getPath()) {
  555. unset($results[$key]);
  556. }
  557. }
  558. foreach ($results as $key => $result) {
  559. $results[$key]['why'] =
  560. 'Matching arcanist project name and working copy directory path.';
  561. }
  562. return $results;
  563. }
  564. public function updateWorkingCopy() {
  565. $this->execxLocal('up');
  566. }
  567. public static function escapeFileNamesForSVN(array $files) {
  568. foreach ($files as $k => $file) {
  569. $files[$k] = self::escapeFileNameForSVN($file);
  570. }
  571. return $files;
  572. }
  573. public static function escapeFileNameForSVN($file) {
  574. // SVN interprets "x@1" as meaning "file x at revision 1", which is not
  575. // intended for files named "sprite@2x.png" or similar. For files with an
  576. // "@" in their names, escape them by adding "@" at the end, which SVN
  577. // interprets as "at the working copy revision". There is a special case
  578. // where ".@" means "fail with an error" instead of ". at the working copy
  579. // revision", so avoid escaping "." into ".@".
  580. if (strpos($file, '@') !== false) {
  581. $file = $file.'@';
  582. }
  583. return $file;
  584. }
  585. }