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