4
* Interfaces with Subversion working copies.
6
final class ArcanistSubversionAPI extends ArcanistRepositoryAPI {
9
protected $svnBaseRevisions;
10
protected $svnInfo = array();
12
protected $svnInfoRaw = array();
13
protected $svnDiffRaw = array();
15
private $svnBaseRevisionNumber;
16
private $statusPaths = array();
18
public function getSourceControlSystemName() {
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;
39
protected function buildLocalFuture(array $argv) {
41
$argv[0] = 'svn '.$argv[0];
43
$future = newv('ExecFuture', $argv);
44
$future->setCWD($this->getPath());
48
protected function buildCommitRangeStatus() {
49
// In SVN, the commit range is always "uncommitted changes", so these
50
// statuses are equivalent.
51
return $this->getUncommittedStatus();
54
protected function buildUncommittedStatus() {
55
return $this->getSVNStatus();
58
public function getSVNBaseRevisions() {
59
if ($this->svnBaseRevisions === null) {
60
$this->getSVNStatus();
62
return $this->svnBaseRevisions;
65
public function limitStatusToPaths(array $paths) {
66
$this->statusPaths = $paths;
70
public function getSVNStatus($with_externals = false) {
71
if ($this->svnStatus === null) {
72
if ($this->statusPaths) {
73
list($status) = $this->execxLocal(
77
list($status) = $this->execxLocal('--xml status');
79
$xml = new SimpleXMLElement($status);
84
foreach ($xml->target as $target) {
85
$this->svnBaseRevisions = array();
86
foreach ($target->entry as $entry) {
87
$path = (string)$entry['path'];
88
// On Windows, we get paths with backslash directory separators here.
89
// Normalize them to the format everything else expects and generates.
90
if (phutil_is_windows()) {
91
$path = str_replace(DIRECTORY_SEPARATOR, '/', $path);
95
$props = (string)($entry->{'wc-status'}[0]['props']);
96
$item = (string)($entry->{'wc-status'}[0]['item']);
98
$base = (string)($entry->{'wc-status'}[0]['revision']);
99
$this->svnBaseRevisions[$path] = $base;
106
$mask |= self::FLAG_MODIFIED;
109
throw new Exception("Unrecognized property status '{$props}'.");
112
$mask |= $this->parseSVNStatus($item);
113
if ($item == 'external') {
114
$externals[] = $path;
117
// This is new in or around Subversion 1.6.
118
$tree_conflicts = ($entry->{'wc-status'}[0]['tree-conflicted']);
119
if ((string)$tree_conflicts) {
120
$mask |= self::FLAG_CONFLICT;
123
$files[$path] = $mask;
127
foreach ($files as $path => $mask) {
128
foreach ($externals as $external) {
129
if (!strncmp($path.'/', $external.'/', strlen($external) + 1)) {
130
$files[$path] |= self::FLAG_EXTERNALS;
135
$this->svnStatus = $files;
138
$status = $this->svnStatus;
139
if (!$with_externals) {
140
foreach ($status as $path => $mask) {
141
if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) {
142
unset($status[$path]);
150
private function parseSVNStatus($item) {
153
// We can get 'none' for property changes on a directory.
157
return self::FLAG_EXTERNALS;
159
return self::FLAG_UNTRACKED;
161
return self::FLAG_OBSTRUCTED;
163
return self::FLAG_MISSING;
165
return self::FLAG_ADDED;
167
// This is the result of "svn rm"-ing a file, putting another one
168
// in place of it, and then "svn add"-ing the new file. Just treat
169
// this as equivalent to "modified".
170
return self::FLAG_MODIFIED;
172
return self::FLAG_MODIFIED;
174
return self::FLAG_DELETED;
176
return self::FLAG_CONFLICT;
178
return self::FLAG_INCOMPLETE;
180
throw new Exception("Unrecognized item status '{$item}'.");
184
public function addToCommit(array $paths) {
185
$add = array_filter($paths, 'Filesystem::pathExists');
191
if ($add != $paths) {
194
array_diff($paths, $add));
196
$this->svnStatus = null;
199
public function getSVNProperty($path, $property) {
200
list($stdout) = execx(
201
'svn propget %s %s@',
203
$this->getPath($path));
204
return trim($stdout);
207
public function getSourceControlPath() {
208
return idx($this->getSVNInfo('/'), 'URL');
211
public function getSourceControlBaseRevision() {
212
$info = $this->getSVNInfo('/');
213
return $info['URL'].'@'.$this->getSVNBaseRevisionNumber();
216
public function getCanonicalRevisionName($string) {
217
// TODO: This could be more accurate, but is only used by `arc browse`
220
if (is_numeric($string)) {
226
public function getSVNBaseRevisionNumber() {
227
if ($this->svnBaseRevisionNumber) {
228
return $this->svnBaseRevisionNumber;
230
$info = $this->getSVNInfo('/');
231
return $info['Revision'];
234
public function overrideSVNBaseRevisionNumber($effective_base_revision) {
235
$this->svnBaseRevisionNumber = $effective_base_revision;
239
public function getBranchName() {
240
$info = $this->getSVNInfo('/');
241
$repo_root = idx($info, 'Repository Root');
242
$repo_root_length = strlen($repo_root);
243
$url = idx($info, 'URL');
244
if (substr($url, 0, $repo_root_length) == $repo_root) {
245
return substr($url, $repo_root_length);
250
public function getRemoteURI() {
251
return idx($this->getSVNInfo('/'), 'Repository Root');
254
public function buildInfoFuture($path) {
256
// When the root of a working copy is referenced by a symlink and you
257
// execute 'svn info' on that symlink, svn fails. This is a longstanding
260
// See http://subversion.tigris.org/issues/show_bug.cgi?id=2305
264
// $ ln -s working_copy working_link
265
// $ svn info working_copy # ok
266
// $ svn info working_link # fails
268
// Work around this by cd-ing into the directory before executing
270
return $this->buildLocalFuture(array('info .'));
272
// Note: here and elsewhere we need to append "@" to the path because if
273
// a file has a literal "@" in it, everything after that will be
274
// interpreted as a revision. By appending "@" with no argument, SVN
275
// parses it properly.
276
return $this->buildLocalFuture(array('info %s@', $this->getPath($path)));
280
public function buildDiffFuture($path) {
281
$root = phutil_get_library_root('arcanist');
283
// The "--depth empty" flag prevents us from picking up changes in
284
// children when we run 'diff' against a directory. Specifically, when a
285
// user has added or modified some directory "example/", we want to return
286
// ONLY changes to that directory when given it as a path. If we run
287
// without "--depth empty", svn will give us changes to the directory
288
// itself (such as property changes) and also give us changes to any
289
// files within the directory (basically, implicit recursion). We don't
290
// want that, so prevent recursive diffing. This flag does not work if the
291
// directory is newly added (see T5555) so we need to filter the results
292
// out later as well.
294
if (phutil_is_windows()) {
295
// TODO: Provide a binary_safe_diff script for Windows.
296
// TODO: Provide a diff command which can take lines of context somehow.
297
return $this->buildLocalFuture(
299
'diff --depth empty %s',
303
$diff_bin = $root.'/../scripts/repository/binary_safe_diff.sh';
304
$diff_cmd = Filesystem::resolvePath($diff_bin);
305
return $this->buildLocalFuture(
307
'diff --depth empty --diff-cmd %s -x -U%d %s',
309
$this->getDiffLinesOfContext(),
315
public function primeSVNInfoResult($path, $result) {
316
$this->svnInfoRaw[$path] = $result;
320
public function primeSVNDiffResult($path, $result) {
321
$this->svnDiffRaw[$path] = $result;
325
public function getSVNInfo($path) {
326
if (empty($this->svnInfo[$path])) {
328
if (empty($this->svnInfoRaw[$path])) {
329
$this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve();
332
list($err, $stdout) = $this->svnInfoRaw[$path];
335
"Error #{$err} executing svn info against '{$path}'.");
338
// TODO: Hack for Windows.
339
$stdout = str_replace("\r\n", "\n", $stdout);
343
'/^(Revision): (\d+)$/m',
344
'/^(Last Changed Author): (\S+)$/m',
345
'/^(Last Changed Rev): (\d+)$/m',
346
'/^(Last Changed Date): (.+) \(.+\)$/m',
347
'/^(Copied From URL): (\S+)$/m',
348
'/^(Copied From Rev): (\d+)$/m',
349
'/^(Repository Root): (\S+)$/m',
350
'/^(Repository UUID): (\S+)$/m',
351
'/^(Node Kind): (\S+)$/m',
355
foreach ($patterns as $pattern) {
357
if (preg_match($pattern, $stdout, $matches)) {
358
$result[$matches[1]] = $matches[2];
362
if (isset($result['Last Changed Date'])) {
363
$result['Last Changed Date'] = strtotime($result['Last Changed Date']);
366
if (empty($result)) {
367
throw new Exception('Unable to parse SVN info.');
370
$this->svnInfo[$path] = $result;
373
return $this->svnInfo[$path];
377
public function getRawDiffText($path) {
378
$status = $this->getSVNStatus();
379
if (!isset($status[$path])) {
383
$status = $status[$path];
385
// Build meaningful diff text for "svn copy" operations.
386
if ($status & ArcanistRepositoryAPI::FLAG_ADDED) {
387
$info = $this->getSVNInfo($path);
388
if (!empty($info['Copied From URL'])) {
389
return $this->buildSyntheticAdditionDiff(
391
$info['Copied From URL'],
392
$info['Copied From Rev']);
396
// If we run "diff" on a binary file which doesn't have the "svn:mime-type"
397
// of "application/octet-stream", `diff' will explode in a rain of
398
// unhelpful hellfire as it tries to build a textual diff of the two
399
// files. We just fix this inline since it's pretty unambiguous.
400
// TODO: Move this to configuration?
402
if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) {
403
// Check if the file is deleted first; SVN will complain if we try to
404
// get properties of a deleted file.
405
if ($status & ArcanistRepositoryAPI::FLAG_DELETED) {
408
===================================================================
409
Cannot display: file marked as a binary type.
410
svn:mime-type = application/octet-stream
415
$mime = $this->getSVNProperty($path, 'svn:mime-type');
416
if ($mime != 'application/octet-stream') {
418
'svn propset svn:mime-type application/octet-stream %s',
419
self::escapeFileNameForSVN($this->getPath($path)));
423
if (empty($this->svnDiffRaw[$path])) {
424
$this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve();
427
list($err, $stdout, $stderr) = $this->svnDiffRaw[$path];
429
// Note: GNU Diff returns 2 when SVN hands it binary files to diff and they
430
// differ. This is not an error; it is documented behavior. But SVN isn't
431
// happy about it. SVN will exit with code 1 and return the string below.
432
if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") {
434
"svn diff returned unexpected error code: $err\n".
439
if ($err == 0 && empty($stdout)) {
440
// If there are no changes, 'diff' exits with no output, but that means
441
// we can not distinguish between empty and unmodified files. Build a
442
// synthetic "diff" without any changes in it.
443
return $this->buildSyntheticUnchangedDiff($path);
449
protected function buildSyntheticAdditionDiff($path, $source, $rev) {
450
$type = $this->getSVNProperty($path, 'svn:mime-type');
451
if ($type == 'application/octet-stream') {
454
===================================================================
455
Cannot display: file marked as a binary type.
456
svn:mime-type = application/octet-stream
461
if (is_dir($this->getPath($path))) {
465
$data = Filesystem::readFile($this->getPath($path));
466
list($orig) = execx('svn cat %s@%s', $source, $rev);
468
$src = new TempFile();
469
$dst = new TempFile();
470
Filesystem::writeFile($src, $orig);
471
Filesystem::writeFile($dst, $data);
473
list($err, $diff) = exec_manual(
474
'diff -L a/%s -L b/%s -U%d %s %s',
475
str_replace($this->getSourceControlPath().'/', '', $source),
477
$this->getDiffLinesOfContext(),
481
if ($err == 1) { // 1 means there are differences.
484
===================================================================
489
return $this->buildSyntheticUnchangedDiff($path);
493
protected function buildSyntheticUnchangedDiff($path) {
494
$full_path = $this->getPath($path);
495
if (is_dir($full_path)) {
499
if (!file_exists($full_path)) {
503
$data = Filesystem::readFile($full_path);
504
$lines = explode("\n", $data);
505
$len = count($lines);
506
foreach ($lines as $key => $line) {
507
$lines[$key] = ' '.$line;
509
$lines = implode("\n", $lines);
512
===================================================================
513
--- {$path} (synthetic)
514
+++ {$path} (synthetic)
515
@@ -1,{$len} +1,{$len} @@
521
public function getAllFiles() {
522
// TODO: Handle paths with newlines.
523
$future = $this->buildLocalFuture(array('list -R'));
524
return new PhutilCallbackFilterIterator(
525
new LinesOfALargeExecFuture($future),
526
array($this, 'filterFiles'));
529
public function getChangedFiles($since_commit) {
532
if (preg_match('/(.*)@(.*)/', $since_commit, $match)) {
533
list(, $url, $since_commit) = $match;
535
// TODO: Handle paths with newlines.
536
list($stdout) = $this->execxLocal(
537
'--xml diff --revision %s:HEAD --summarize %s',
540
$xml = new SimpleXMLElement($stdout);
543
foreach ($xml->paths[0]->path as $path) {
544
$return[(string)$path] = $this->parseSVNStatus($path['item']);
549
public function filterFiles($path) {
550
// NOTE: SVN uses '/' also on Windows.
551
if ($path == '' || substr($path, -1) == '/') {
557
public function getBlame($path) {
560
list($stdout) = $this->execxLocal('blame %s', $path);
562
$stdout = trim($stdout);
563
if (!strlen($stdout)) {
568
foreach (explode("\n", $stdout) as $line) {
570
if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) {
571
throw new Exception("Bad blame? `{$line}'");
575
$blame[] = array($author, $revision);
581
public function getOriginalFileData($path) {
582
// SVN issues warnings for nonexistent paths, directories, etc., but still
583
// returns no error code. However, for new paths in the working copy it
584
// fails. Assume that failure means the original file does not exist.
585
list($err, $stdout) = $this->execManualLocal('cat %s@', $path);
592
public function getCurrentFileData($path) {
593
$full_path = $this->getPath($path);
594
if (Filesystem::pathExists($full_path)) {
595
return Filesystem::readFile($full_path);
600
public function getRepositoryUUID() {
601
$info = $this->getSVNInfo('/');
602
return $info['Repository UUID'];
605
public function getLocalCommitInformation() {
609
public function isHistoryDefaultImmutable() {
613
public function supportsAmend() {
617
public function supportsCommitRanges() {
621
public function supportsLocalCommits() {
625
public function hasLocalCommit($commit) {
629
public function getWorkingCopyRevision() {
630
return $this->getSourceControlBaseRevision();
633
public function supportsLocalBranchMerge() {
637
public function getFinalizedRevisionMessage() {
638
// In other VCSes we give push instructions here, but it never makes sense
643
public function loadWorkingCopyDifferentialRevisions(
644
ConduitClient $conduit,
647
// We don't have much to go on in SVN, look for revisions that came from
648
// this directory and belong to the same project.
650
$project = $this->getWorkingCopyIdentity()->getProjectID();
655
$results = $conduit->callMethodSynchronous(
656
'differential.query',
658
'arcanistProjects' => array($project),
661
foreach ($results as $key => $result) {
662
if ($result['sourcePath'] != $this->getPath()) {
663
unset($results[$key]);
667
foreach ($results as $key => $result) {
668
$results[$key]['why'] =
669
'Matching arcanist project name and working copy directory path.';
675
public function updateWorkingCopy() {
676
$this->execxLocal('up');
679
public static function escapeFileNamesForSVN(array $files) {
680
foreach ($files as $k => $file) {
681
$files[$k] = self::escapeFileNameForSVN($file);
686
public static function escapeFileNameForSVN($file) {
687
// SVN interprets "x@1" as meaning "file x at revision 1", which is not
688
// intended for files named "sprite@2x.png" or similar. For files with an
689
// "@" in their names, escape them by adding "@" at the end, which SVN
690
// interprets as "at the working copy revision". There is a special case
691
// where ".@" means "fail with an error" instead of ". at the working copy
692
// revision", so avoid escaping "." into ".@".
694
if (strpos($file, '@') !== false) {