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