Completed
Push — master ( da30fd...bb16f0 )
by Alexander
04:44
created

Connector::getWorkingCopyMissing()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 12
ccs 8
cts 8
cp 1
rs 9.4285
cc 3
eloc 6
nc 3
nop 1
crap 3
1
<?php
2
/**
3
 * This file is part of the SVN-Buddy library.
4
 * For the full copyright and license information, please view
5
 * the LICENSE file that was distributed with this source code.
6
 *
7
 * @copyright Alexander Obuhovich <[email protected]>
8
 * @link      https://github.com/console-helpers/svn-buddy
9
 */
10
11
namespace ConsoleHelpers\SVNBuddy\Repository\Connector;
12
13
14
use ConsoleHelpers\ConsoleKit\ConsoleIO;
15
use ConsoleHelpers\SVNBuddy\Cache\CacheManager;
16
use ConsoleHelpers\ConsoleKit\Config\ConfigEditor;
17
use ConsoleHelpers\SVNBuddy\Exception\RepositoryCommandException;
18
use ConsoleHelpers\SVNBuddy\Process\IProcessFactory;
19
use ConsoleHelpers\SVNBuddy\Repository\Parser\RevisionListParser;
20
21
/**
22
 * Executes command on the repository.
23
 */
24
class Connector
25
{
26
27
	const STATUS_NORMAL = 'normal';
28
29
	const STATUS_ADDED = 'added';
30
31
	const STATUS_CONFLICTED = 'conflicted';
32
33
	const STATUS_UNVERSIONED = 'unversioned';
34
35
	const STATUS_EXTERNAL = 'external';
36
37
	const STATUS_MISSING = 'missing';
38
39
	const STATUS_NONE = 'none';
40
41
	const URL_REGEXP = '#([\w]*)://([^/@\s\']+@)?([^/@:\s\']+)(:\d+)?([^@\s\']*)?#';
42
43
	const SVN_INFO_CACHE_DURATION = '1 year';
44
45
	const SVN_CAT_CACHE_DURATION = '1 month';
46
47
	/**
48
	 * Reference to configuration.
49
	 *
50
	 * @var ConfigEditor
51
	 */
52
	private $_configEditor;
53
54
	/**
55
	 * Process factory.
56
	 *
57
	 * @var IProcessFactory
58
	 */
59
	private $_processFactory;
60
61
	/**
62
	 * Console IO.
63
	 *
64
	 * @var ConsoleIO
65
	 */
66
	private $_io;
67
68
	/**
69
	 * Cache manager.
70
	 *
71
	 * @var CacheManager
72
	 */
73
	private $_cacheManager;
74
75
	/**
76
	 * Revision list parser.
77
	 *
78
	 * @var RevisionListParser
79
	 */
80
	private $_revisionListParser;
81
82
	/**
83
	 * Path to an svn command.
84
	 *
85
	 * @var string
86
	 */
87
	private $_svnCommand = 'svn';
88
89
	/**
90
	 * Cache duration for next invoked command.
91
	 *
92
	 * @var mixed
93
	 */
94
	private $_nextCommandCacheDuration = null;
95
96
	/**
97
	 * Whatever to cache last repository revision or not.
98
	 *
99
	 * @var mixed
100
	 */
101
	private $_lastRevisionCacheDuration = null;
102
103
	/**
104
	 * Creates repository connector.
105
	 *
106
	 * @param ConfigEditor       $config_editor        ConfigEditor.
107
	 * @param IProcessFactory    $process_factory      Process factory.
108
	 * @param ConsoleIO          $io                   Console IO.
109
	 * @param CacheManager       $cache_manager        Cache manager.
110
	 * @param RevisionListParser $revision_list_parser Revision list parser.
111
	 */
112 94
	public function __construct(
113
		ConfigEditor $config_editor,
114
		IProcessFactory $process_factory,
115
		ConsoleIO $io,
1 ignored issue
show
Comprehensibility introduced by
Avoid variables with short names like $io. Configured minimum length is 3.

Short variable names may make your code harder to understand. Variable names should be self-descriptive. This check looks for variable names who are shorter than a configured minimum.

Loading history...
116
		CacheManager $cache_manager,
117
		RevisionListParser $revision_list_parser
118
	) {
119 94
		$this->_configEditor = $config_editor;
120 94
		$this->_processFactory = $process_factory;
121 94
		$this->_io = $io;
122 94
		$this->_cacheManager = $cache_manager;
123 94
		$this->_revisionListParser = $revision_list_parser;
124
125 94
		$cache_duration = $this->_configEditor->get('repository-connector.last-revision-cache-duration');
126
127 94
		if ( (string)$cache_duration === '' || substr($cache_duration, 0, 1) === '0' ) {
128 4
			$cache_duration = 0;
129 4
		}
130
131 94
		$this->_lastRevisionCacheDuration = $cache_duration;
132
133 94
		$this->prepareSvnCommand();
134 94
	}
135
136
	/**
137
	 * Prepares static part of svn command to be used across the script.
138
	 *
139
	 * @return void
140
	 */
141 94
	protected function prepareSvnCommand()
142
	{
143 94
		$username = $this->_configEditor->get('repository-connector.username');
144 94
		$password = $this->_configEditor->get('repository-connector.password');
145
146 94
		$this->_svnCommand .= ' --non-interactive';
147
148 94
		if ( $username ) {
149 17
			$this->_svnCommand .= ' --username ' . $username;
150 17
		}
151
152 94
		if ( $password ) {
153 17
			$this->_svnCommand .= ' --password ' . $password;
154 17
		}
155 94
	}
156
157
	/**
158
	 * Builds a command.
159
	 *
160
	 * @param string      $sub_command  Sub command.
161
	 * @param string|null $param_string Parameter string.
162
	 *
163
	 * @return Command
164
	 */
165 50
	public function getCommand($sub_command, $param_string = null)
166
	{
167 50
		$command_line = $this->buildCommand($sub_command, $param_string);
168
169 49
		$command = new Command(
170 49
			$command_line,
171 49
			$this->_io,
172 49
			$this->_cacheManager,
173 49
			$this->_processFactory
174 49
		);
175
176 49
		if ( isset($this->_nextCommandCacheDuration) ) {
177 24
			$command->setCacheDuration($this->_nextCommandCacheDuration);
178 24
			$this->_nextCommandCacheDuration = null;
179 24
		}
180
181 49
		return $command;
182
	}
183
184
	/**
185
	 * Builds command from given arguments.
186
	 *
187
	 * @param string $sub_command  Command.
188
	 * @param string $param_string Parameter string.
189
	 *
190
	 * @return string
191
	 * @throws \InvalidArgumentException When command contains spaces.
192
	 */
193 50
	protected function buildCommand($sub_command, $param_string = null)
194
	{
195 50
		if ( strpos($sub_command, ' ') !== false ) {
196 1
			throw new \InvalidArgumentException('The "' . $sub_command . '" sub-command contains spaces.');
197
		}
198
199 49
		$command_line = $this->_svnCommand;
200
201 49
		if ( !empty($sub_command) ) {
202 44
			$command_line .= ' ' . $sub_command;
203 44
		}
204
205 49
		if ( !empty($param_string) ) {
206 47
			$command_line .= ' ' . $param_string;
207 47
		}
208
209 49
		$command_line = preg_replace_callback(
210 49
			'/\{([^\}]*)\}/',
211 49
			function (array $matches) {
212 41
				return escapeshellarg($matches[1]);
213 49
			},
214
			$command_line
215 49
		);
216
217 49
		return $command_line;
218
	}
219
220
	/**
221
	 * Sets cache configuration for next created command.
222
	 *
223
	 * @param mixed $cache_duration Cache duration.
224
	 *
225
	 * @return self
226
	 */
227 25
	public function withCache($cache_duration)
228
	{
229 25
		$this->_nextCommandCacheDuration = $cache_duration;
230
231 25
		return $this;
232
	}
233
234
	/**
235
	 * Returns property value.
236
	 *
237
	 * @param string $name        Property name.
238
	 * @param string $path_or_url Path to get property from.
239
	 * @param mixed  $revision    Revision.
240
	 *
241
	 * @return string
242
	 * @throws RepositoryCommandException When other, then missing property exception happens.
243
	 */
244 6
	public function getProperty($name, $path_or_url, $revision = null)
245
	{
246 6
		$param_string = $name . ' {' . $path_or_url . '}';
247
248 6
		if ( isset($revision) ) {
249 5
			$param_string .= ' --revision ' . $revision;
250 5
		}
251
252
		// The "null" for non-existing properties is never returned, because output is converted to string.
253 6
		$property_value = '';
254
255
		try {
256 6
			$property_value = $this->getCommand('propget', $param_string)->run();
257
		}
258 6
		catch ( RepositoryCommandException $e ) {
259
			// Preserve SVN 1.8- behavior, where reading value of non-existing property returned an empty string.
260 1
			if ( $e->getCode() !== RepositoryCommandException::SVN_ERR_BASE ) {
261
				throw $e;
262
			}
263
		}
264
265 6
		return $property_value;
266
	}
267
268
	/**
269
	 * Returns relative path of given path/url to the root of the repository.
270
	 *
271
	 * @param string $path_or_url Path or url.
272
	 *
273
	 * @return string
274
	 */
275 3
	public function getRelativePath($path_or_url)
276
	{
277 3
		$svn_info_entry = $this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION);
278
279 3
		return preg_replace(
280 3
			'/^' . preg_quote($svn_info_entry->repository->root, '/') . '/',
281 3
			'',
282 3
			(string)$svn_info_entry->url,
283
			1
284 3
		);
285
	}
286
287
	/**
288
	 * Returns repository root url from given path/url.
289
	 *
290
	 * @param string $path_or_url Path or url.
291
	 *
292
	 * @return string
293
	 */
294 3
	public function getRootUrl($path_or_url)
295
	{
296 3
		return (string)$this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION)->repository->root;
297
	}
298
299
	/**
300
	 * Determines if path is a root of the ref.
301
	 *
302
	 * @param string $path Path to a file.
303
	 *
304
	 * @return boolean
305
	 */
306 13
	public function isRefRoot($path)
307
	{
308 13
		$ref = $this->getRefByPath($path);
309
310 13
		if ( $ref === false ) {
311 4
			return false;
312
		}
313
314 9
		return preg_match('#/' . preg_quote($ref, '#') . '/$#', $path) > 0;
315
	}
316
317
	/**
318
	 * Detects ref from given path.
319
	 *
320
	 * @param string $path Path to a file.
321
	 *
322
	 * @return string|boolean
323
	 * @see    getProjectUrl
324
	 */
325 22
	public function getRefByPath($path)
326
	{
327 22
		if ( preg_match('#^.*?/(trunk|branches/[^/]+|tags/[^/]+|releases/[^/]+).*$#', $path, $regs) ) {
328 14
			return $regs[1];
329
		}
330
331 8
		return false;
332
	}
333
334
	/**
335
	 * Returns URL of the working copy.
336
	 *
337
	 * @param string $wc_path Working copy path.
338
	 *
339
	 * @return string
340
	 * @throws RepositoryCommandException When repository command failed to execute.
341
	 */
342 10
	public function getWorkingCopyUrl($wc_path)
343
	{
344 10
		if ( $this->isUrl($wc_path) ) {
345 2
			return $this->removeCredentials($wc_path);
346
		}
347
348
		try {
349 8
			$wc_url = (string)$this->_getSvnInfoEntry($wc_path, self::SVN_INFO_CACHE_DURATION)->url;
350
		}
351 8
		catch ( RepositoryCommandException $e ) {
352 3
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_UPGRADE_REQUIRED ) {
353 2
				$message = explode(PHP_EOL, $e->getMessage());
354
355 2
				$this->_io->writeln(array('', '<error>' . end($message) . '</error>', ''));
356
357 2
				if ( $this->_io->askConfirmation('Run "svn upgrade"', false) ) {
358 1
					$this->getCommand('upgrade', '{' . $wc_path . '}')->runLive();
359
360 1
					return $this->getWorkingCopyUrl($wc_path);
361
				}
362 1
			}
363
364 2
			throw $e;
365
		}
366
367 5
		return $wc_url;
368
	}
369
370
	/**
371
	 * Returns last changed revision on path/url.
372
	 *
373
	 * @param string $path_or_url Path or url.
374
	 *
375
	 * @return integer
376
	 */
377 9
	public function getLastRevision($path_or_url)
378
	{
379
		// Cache "svn info" commands to remote urls, not the working copy.
380 9
		$cache_duration = $this->isUrl($path_or_url) ? $this->_lastRevisionCacheDuration : null;
381
382 9
		return (int)$this->_getSvnInfoEntry($path_or_url, $cache_duration)->commit['revision'];
383
	}
384
385
	/**
386
	 * Determines if given path is in fact an url.
387
	 *
388
	 * @param string $path Path.
389
	 *
390
	 * @return boolean
391
	 */
392 28
	public function isUrl($path)
393
	{
394 28
		return strpos($path, '://') !== false;
395
	}
396
397
	/**
398
	 * Removes credentials from url.
399
	 *
400
	 * @param string $url URL.
401
	 *
402
	 * @return string
403
	 * @throws \InvalidArgumentException When non-url given.
404
	 */
405 17
	public function removeCredentials($url)
406
	{
407 17
		if ( !$this->isUrl($url) ) {
408 1
			throw new \InvalidArgumentException('Unable to remove credentials from "' . $url . '" path.');
409
		}
410
411 16
		return preg_replace('#^(.*)://(.*)@(.*)$#', '$1://$3', $url);
412
	}
413
414
	/**
415
	 * Returns project url (container for "trunk/branches/tags/releases" folders).
416
	 *
417
	 * @param string $repository_url Repository url.
418
	 *
419
	 * @return string
420
	 * @see    getRefByPath
421
	 */
422 9
	public function getProjectUrl($repository_url)
423
	{
424 9
		if ( preg_match('#^(.*?)/(trunk|branches|tags|releases).*$#', $repository_url, $regs) ) {
425 8
			return $regs[1];
426
		}
427
428
		// When known folder structure not detected consider, that project url was already given.
429 1
		return $repository_url;
430
	}
431
432
	/**
433
	 * Returns "svn info" entry for path or url.
434
	 *
435
	 * @param string $path_or_url    Path or url.
436
	 * @param mixed  $cache_duration Cache duration.
437
	 *
438
	 * @return \SimpleXMLElement
439
	 * @throws \LogicException When unexpected 'svn info' results retrieved.
440
	 */
441 23
	private function _getSvnInfoEntry($path_or_url, $cache_duration = null)
442
	{
443
		// Cache "svn info" commands to remote urls, not the working copy.
444 23
		if ( !isset($cache_duration) && $this->isUrl($path_or_url) ) {
445
			$cache_duration = self::SVN_INFO_CACHE_DURATION;
446
		}
447
448
		// Remove credentials from url, because "svn info" fails, when used on repository root.
449 23
		if ( $this->isUrl($path_or_url) ) {
450 12
			$path_or_url = $this->removeCredentials($path_or_url);
451 12
		}
452
453
		// TODO: When wc path (not url) is given, then credentials can be present in "svn info" result anyway.
454 23
		$svn_info = $this->withCache($cache_duration)->getCommand('info', '--xml {' . $path_or_url . '}')->run();
455
456
		// When getting remote "svn info", then path is last folder only.
457 21
		$svn_info_path = (string)$svn_info->entry['path'];
458
459
		// In SVN 1.7+, when doing "svn info" on repository root url.
460 21
		if ( $svn_info_path === '.' ) {
461 1
			$svn_info_path = $path_or_url;
462 1
		}
463
464 21
		if ( basename($svn_info_path) != basename($path_or_url) ) {
465 1
			throw new \LogicException('The directory "' . $path_or_url . '" not found in "svn info" command results.');
466
		}
467
468 20
		return $svn_info->entry;
469
	}
470
471
	/**
472
	 * Returns revision, when path was added to repository.
473
	 *
474
	 * @param string $url Url.
475
	 *
476
	 * @return integer
477
	 * @throws \InvalidArgumentException When not an url was given.
478
	 */
479
	public function getFirstRevision($url)
480
	{
481
		if ( !$this->isUrl($url) ) {
482
			throw new \InvalidArgumentException('The repository URL "' . $url . '" is invalid.');
483
		}
484
485
		$log = $this->withCache('1 year')->getCommand('log', ' -r 1:HEAD --limit 1 --xml {' . $url . '}')->run();
486
487
		return (int)$log->logentry['revision'];
488
	}
489
490
	/**
491
	 * Returns conflicts in working copy.
492
	 *
493
	 * @param string $wc_path Working copy path.
494
	 *
495
	 * @return array
496
	 */
497 2
	public function getWorkingCopyConflicts($wc_path)
498
	{
499 2
		$ret = array();
500
501 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
502 2
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_CONFLICTED) ) {
503 1
				$ret[] = $path;
504 1
			}
505 2
		}
506
507 2
		return $ret;
508
	}
509
510
	/**
511
	 * Returns missing paths in working copy.
512
	 *
513
	 * @param string $wc_path Working copy path.
514
	 *
515
	 * @return array
516
	 */
517 2
	public function getWorkingCopyMissing($wc_path)
518
	{
519 2
		$ret = array();
520
521 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
522 1
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_MISSING) ) {
523 1
				$ret[] = $path;
524 1
			}
525 2
		}
526
527 2
		return $ret;
528
	}
529
530
	/**
531
	 * Returns compact working copy status.
532
	 *
533
	 * @param string      $wc_path          Working copy path.
534
	 * @param string|null $changelist       Changelist.
535
	 * @param boolean     $with_unversioned With unversioned.
536
	 *
537
	 * @return string
538
	 */
539
	public function getCompactWorkingCopyStatus($wc_path, $changelist = null, $with_unversioned = false)
540
	{
541
		$ret = array();
542
543
		foreach ( $this->getWorkingCopyStatus($wc_path, $changelist, $with_unversioned) as $path => $status ) {
544
			$line = $this->getShortItemStatus($status['item']) . $this->getShortPropertiesStatus($status['props']);
545
			$line .= '   ' . $path;
546
547
			$ret[] = $line;
548
		}
549
550
		return implode(PHP_EOL, $ret);
551
	}
552
553
	/**
554
	 * Returns short item status.
555
	 *
556
	 * @param string $status Status.
557
	 *
558
	 * @return string
559
	 * @throws \InvalidArgumentException When unknown status given.
560
	 */
561
	protected function getShortItemStatus($status)
562
	{
563
		$status_map = array(
564
			'added' => 'A',
565
			'conflicted' => 'C',
566
			'deleted' => 'D',
567
			'external' => 'X',
568
			'ignored' => 'I',
569
			// 'incomplete' => '',
0 ignored issues
show
Unused Code Comprehensibility introduced by
58% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
570
			// 'merged' => '',
0 ignored issues
show
Unused Code Comprehensibility introduced by
58% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
571
			'missing' => '!',
572
			'modified' => 'M',
573
			'none' => ' ',
574
			'normal' => '_',
575
			// 'obstructed' => '',
0 ignored issues
show
Unused Code Comprehensibility introduced by
58% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
576
			'replaced' => 'R',
577
			'unversioned' => '?',
578
		);
579
580
		if ( !isset($status_map[$status]) ) {
581
			throw new \InvalidArgumentException('The "' . $status . '" item status is unknown.');
582
		}
583
584
		return $status_map[$status];
585
	}
586
587
	/**
588
	 * Returns short item status.
589
	 *
590
	 * @param string $status Status.
591
	 *
592
	 * @return string
593
	 * @throws \InvalidArgumentException When unknown status given.
594
	 */
595
	protected function getShortPropertiesStatus($status)
596
	{
597
		$status_map = array(
598
			'conflicted' => 'C',
599
			'modified' => 'M',
600
			'normal' => '_',
601
			'none' => ' ',
602
		);
603
604
		if ( !isset($status_map[$status]) ) {
605
			throw new \InvalidArgumentException('The "' . $status . '" properties status is unknown.');
606
		}
607
608
		return $status_map[$status];
609
	}
610
611
	/**
612
	 * Returns working copy status.
613
	 *
614
	 * @param string      $wc_path          Working copy path.
615
	 * @param string|null $changelist       Changelist.
616
	 * @param boolean     $with_unversioned With unversioned.
617
	 *
618
	 * @return array
619
	 * @throws \InvalidArgumentException When changelist doens't exist.
620
	 */
621 7
	public function getWorkingCopyStatus($wc_path, $changelist = null, $with_unversioned = false)
622
	{
623 7
		$all_paths = array();
624
625 7
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
626
627 7
		if ( !strlen($changelist) ) {
628
			// Accept all entries from "target" and "changelist" nodes.
629 5
			foreach ( $status->children() as $entries ) {
630 5
				$child_name = $entries->getName();
631
632 5
				if ( $child_name === 'target' || $child_name === 'changelist' ) {
633 4
					$all_paths += $this->processStatusEntryNodes($wc_path, $entries);
634 4
				}
635 5
			}
636 5
		}
637
		else {
638
			// Accept all entries from "changelist" node and parent folders from "target" node.
639 2
			foreach ( $status->changelist as $changelist_entries ) {
640 2
				if ( (string)$changelist_entries['name'] === $changelist ) {
641 1
					$all_paths += $this->processStatusEntryNodes($wc_path, $changelist_entries);
642 1
				}
643 2
			}
644
645 2
			if ( !$all_paths ) {
646 1
				throw new \InvalidArgumentException('The "' . $changelist . '" changelist doens\'t exist.');
647
			}
648
649 1
			$parent_paths = $this->getParentPaths(array_keys($all_paths));
650
651 1
			foreach ( $status->target as $target_entries ) {
652 1
				foreach ( $this->processStatusEntryNodes($wc_path, $target_entries) as $path => $path_data ) {
653 1
					if ( in_array($path, $parent_paths) ) {
654 1
						$all_paths[$path] = $path_data;
655 1
					}
656 1
				}
657 1
			}
658
659 1
			ksort($all_paths, SORT_STRING);
660
		}
661
662
		// Exclude paths, that haven't changed (e.g. from changelists).
663 6
		$changed_paths = array();
664
665 6
		foreach ( $all_paths as $path => $status ) {
666 5
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_NORMAL) ) {
667 3
				continue;
668
			}
669
670 5
			if ( !$with_unversioned && $this->isWorkingCopyPathStatus($status, self::STATUS_UNVERSIONED) ) {
671 3
				continue;
672
			}
673
674 5
			$changed_paths[$path] = $status;
675 6
		}
676
677 6
		return $changed_paths;
678
	}
679
680
	/**
681
	 * Processes "entry" nodes from "svn status" command.
682
	 *
683
	 * @param string            $wc_path Working copy path.
684
	 * @param \SimpleXMLElement $entries Entries.
685
	 *
686
	 * @return array
687
	 */
688 5
	protected function processStatusEntryNodes($wc_path, \SimpleXMLElement $entries)
689
	{
690 5
		$ret = array();
691
692 5
		foreach ( $entries as $entry ) {
693 5
			$path = (string)$entry['path'];
694 5
			$path = $path === $wc_path ? '.' : str_replace($wc_path . '/', '', $path);
695
696 5
			$ret[$path] = array(
697 5
				'item' => (string)$entry->{'wc-status'}['item'],
698 5
				'props' => (string)$entry->{'wc-status'}['props'],
699 5
				'tree-conflicted' => (string)$entry->{'wc-status'}['tree-conflicted'] === 'true',
700
			);
701 5
		}
702
703 5
		return $ret;
704
	}
705
706
	/**
707
	 * Detects specific path status.
708
	 *
709
	 * @param array  $status      Path status.
710
	 * @param string $path_status Expected path status.
711
	 *
712
	 * @return boolean
713
	 */
714 5
	protected function isWorkingCopyPathStatus(array $status, $path_status)
715
	{
716 5
		$tree_conflicted = $status['tree-conflicted'];
717
718 5
		if ( $path_status === self::STATUS_NORMAL ) {
719
			// Normal if all of 3 are normal.
720 5
			return $status['item'] === $path_status && $status['props'] === $path_status && !$tree_conflicted;
721
		}
722 5
		elseif ( $path_status === self::STATUS_CONFLICTED ) {
723
			// Conflict if any of 3 has conflict.
724 2
			return $status['item'] === $path_status || $status['props'] === $path_status || $tree_conflicted;
725
		}
726 5
		elseif ( $path_status === self::STATUS_UNVERSIONED ) {
727 5
			return $status['item'] === $path_status && $status['props'] === self::STATUS_NONE;
728
		}
729
730 1
		return $status['item'] === $path_status;
731
	}
732
733
	/**
734
	 * Returns parent paths from given paths.
735
	 *
736
	 * @param array $paths Paths.
737
	 *
738
	 * @return array
739
	 */
740 1
	protected function getParentPaths(array $paths)
741
	{
742 1
		$ret = array();
743
744 1
		foreach ( $paths as $path ) {
745 1
			while ( $path !== '.' ) {
746 1
				$path = dirname($path);
747 1
				$ret[] = $path;
748 1
			}
749 1
		}
750
751 1
		return array_unique($ret);
752
	}
753
754
	/**
755
	 * Returns working copy changelists.
756
	 *
757
	 * @param string $wc_path Working copy path.
758
	 *
759
	 * @return array
760
	 */
761 2
	public function getWorkingCopyChangelists($wc_path)
762
	{
763 2
		$ret = array();
764 2
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
765
766 2
		foreach ( $status->changelist as $changelist ) {
767 1
			$ret[] = (string)$changelist['name'];
768 2
		}
769
770 2
		sort($ret, SORT_STRING);
771
772 2
		return $ret;
773
	}
774
775
	/**
776
	 * Returns revisions of paths in a working copy.
777
	 *
778
	 * @param string $wc_path Working copy path.
779
	 *
780
	 * @return array
781
	 */
782
	public function getWorkingCopyRevisions($wc_path)
783
	{
784
		$revisions = array();
785
		$status = $this->getCommand('status', '--xml --verbose {' . $wc_path . '}')->run();
786
787
		foreach ( $status->target as $target ) {
788
			if ( (string)$target['path'] !== $wc_path ) {
789
				continue;
790
			}
791
792
			foreach ( $target as $entry ) {
793
				$revision = (int)$entry->{'wc-status'}['revision'];
794
				$revisions[$revision] = true;
795
			}
796
		}
797
798
		// The "-1" revision happens, when external is deleted.
799
		// The "0" happens for not committed paths (e.g. added).
800
		unset($revisions[-1], $revisions[0]);
801
802
		return array_keys($revisions);
803
	}
804
805
	/**
806
	 * Determines if there is a working copy on a given path.
807
	 *
808
	 * @param string $path Path.
809
	 *
810
	 * @return boolean
811
	 * @throws \InvalidArgumentException When path isn't found.
812
	 * @throws RepositoryCommandException When repository command failed to execute.
813
	 */
814
	public function isWorkingCopy($path)
815
	{
816
		if ( $this->isUrl($path) || !file_exists($path) ) {
817
			throw new \InvalidArgumentException('Path "' . $path . '" not found.');
818
		}
819
820
		try {
821
			$wc_url = $this->getWorkingCopyUrl($path);
822
		}
823
		catch ( RepositoryCommandException $e ) {
824
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_NOT_WORKING_COPY ) {
825
				return false;
826
			}
827
828
			throw $e;
829
		}
830
831
		return $wc_url != '';
832
	}
833
834
	/**
835
	 * Returns list of just merged revisions.
836
	 *
837
	 * @param string $wc_path Working copy path, where merge happens.
838
	 *
839
	 * @return array
840
	 */
841 2
	public function getFreshMergedRevisions($wc_path)
842
	{
843 2
		$final_paths = array();
844 2
		$old_paths = $this->getMergedRevisions($wc_path, 'BASE');
845 2
		$new_paths = $this->getMergedRevisions($wc_path);
846
847 2
		if ( $old_paths === $new_paths ) {
848 1
			return array();
849
		}
850
851 1
		foreach ( $new_paths as $new_path => $new_merged_revisions ) {
852 1
			if ( !isset($old_paths[$new_path]) ) {
853
				// Merge from new path.
854 1
				$final_paths[$new_path] = $this->_revisionListParser->expandRanges(
855 1
					explode(',', $new_merged_revisions)
856 1
				);
857 1
			}
858 1
			elseif ( $new_merged_revisions != $old_paths[$new_path] ) {
859
				// Merge on existing path.
860 1
				$new_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
861 1
					explode(',', $new_merged_revisions)
862 1
				);
863 1
				$old_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
864 1
					explode(',', $old_paths[$new_path])
865 1
				);
866 1
				$final_paths[$new_path] = array_values(
867 1
					array_diff($new_merged_revisions_parsed, $old_merged_revisions_parsed)
868 1
				);
869 1
			}
870 1
		}
871
872 1
		return $final_paths;
873
	}
874
875
	/**
876
	 * Returns list of merged revisions per path.
877
	 *
878
	 * @param string  $wc_path  Merge target: working copy path.
879
	 * @param integer $revision Revision.
880
	 *
881
	 * @return array
882
	 */
883 2
	protected function getMergedRevisions($wc_path, $revision = null)
884
	{
885 2
		$paths = array();
886
887 2
		$merge_info = $this->getProperty('svn:mergeinfo', $wc_path, $revision);
888 2
		$merge_info = array_filter(explode("\n", $merge_info));
889
890 2
		foreach ( $merge_info as $merge_info_line ) {
891 2
			list($path, $revisions) = explode(':', $merge_info_line, 2);
892 2
			$paths[$path] = $revisions;
893 2
		}
894
895 2
		return $paths;
896
	}
897
898
	/**
899
	 * Returns file contents at given revision.
900
	 *
901
	 * @param string  $path_or_url Path or url.
902
	 * @param integer $revision    Revision.
903
	 *
904
	 * @return string
905
	 */
906 1
	public function getFileContent($path_or_url, $revision)
907
	{
908 1
		return $this
909 1
			->withCache(self::SVN_CAT_CACHE_DURATION)
910 1
			->getCommand('cat', '{' . $path_or_url . '} --revision ' . $revision)
911 1
			->run();
912
	}
913
914
}
915