Failed Conditions
Push — master ( c1b2a8...af40d0 )
by Alexander
04:07
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
20
/**
21
 * Executes command on the repository.
22
 */
23
class Connector
24
{
25
26
	const STATUS_NORMAL = 'normal';
27
28
	const STATUS_ADDED = 'added';
29
30
	const STATUS_CONFLICTED = 'conflicted';
31
32
	const STATUS_UNVERSIONED = 'unversioned';
33
34
	const STATUS_EXTERNAL = 'external';
35
36
	const STATUS_MISSING = 'missing';
37
38
	const STATUS_NONE = 'none';
39
40
	const URL_REGEXP = '#([\w]*)://([^/@\s\']+@)?([^/@:\s\']+)(:\d+)?([^@\s\']*)?#';
41
42
	const SVN_INFO_CACHE_DURATION = '1 year';
43
44
	/**
45
	 * Reference to configuration.
46
	 *
47
	 * @var ConfigEditor
48
	 */
49
	private $_configEditor;
50
51
	/**
52
	 * Process factory.
53
	 *
54
	 * @var IProcessFactory
55
	 */
56
	private $_processFactory;
57
58
	/**
59
	 * Console IO.
60
	 *
61
	 * @var ConsoleIO
62
	 */
63
	private $_io;
64
65
	/**
66
	 * Cache manager.
67
	 *
68
	 * @var CacheManager
69
	 */
70
	private $_cacheManager;
71
72
	/**
73
	 * Path to an svn command.
74
	 *
75
	 * @var string
76
	 */
77
	private $_svnCommand = 'svn';
78
79
	/**
80
	 * Cache duration for next invoked command.
81
	 *
82
	 * @var mixed
83
	 */
84
	private $_nextCommandCacheDuration = null;
85
86
	/**
87
	 * Whatever to cache last repository revision or not.
88
	 *
89
	 * @var mixed
90
	 */
91
	private $_lastRevisionCacheDuration = null;
92
93
	/**
94
	 * Creates repository connector.
95
	 *
96
	 * @param ConfigEditor    $config_editor   ConfigEditor.
97
	 * @param IProcessFactory $process_factory Process factory.
98
	 * @param ConsoleIO       $io              Console IO.
99
	 * @param CacheManager    $cache_manager   Cache manager.
100
	 */
101 84
	public function __construct(
102
		ConfigEditor $config_editor,
103
		IProcessFactory $process_factory,
104
		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...
105
		CacheManager $cache_manager
106
	) {
107 84
		$this->_configEditor = $config_editor;
108 84
		$this->_processFactory = $process_factory;
109 84
		$this->_io = $io;
110 84
		$this->_cacheManager = $cache_manager;
111
112 84
		$cache_duration = $this->_configEditor->get('repository-connector.last-revision-cache-duration');
113
114 84
		if ( (string)$cache_duration === '' || substr($cache_duration, 0, 1) === '0' ) {
115 4
			$cache_duration = 0;
116 4
		}
117
118 84
		$this->_lastRevisionCacheDuration = $cache_duration;
119
120 84
		$this->prepareSvnCommand();
121 84
	}
122
123
	/**
124
	 * Prepares static part of svn command to be used across the script.
125
	 *
126
	 * @return void
127
	 */
128 84
	protected function prepareSvnCommand()
129
	{
130 84
		$username = $this->_configEditor->get('repository-connector.username');
131 84
		$password = $this->_configEditor->get('repository-connector.password');
132
133 84
		$this->_svnCommand .= ' --non-interactive';
134
135 84
		if ( $username ) {
136 15
			$this->_svnCommand .= ' --username ' . $username;
137 15
		}
138
139 84
		if ( $password ) {
140 15
			$this->_svnCommand .= ' --password ' . $password;
141 15
		}
142 84
	}
143
144
	/**
145
	 * Builds a command.
146
	 *
147
	 * @param string      $sub_command  Sub command.
148
	 * @param string|null $param_string Parameter string.
149
	 *
150
	 * @return Command
151
	 */
152 41
	public function getCommand($sub_command, $param_string = null)
153
	{
154 41
		$command_line = $this->buildCommand($sub_command, $param_string);
155
156 40
		$command = new Command(
157 40
			$command_line,
158 40
			$this->_io,
159 40
			$this->_cacheManager,
160 40
			$this->_processFactory
161 40
		);
162
163 40
		if ( isset($this->_nextCommandCacheDuration) ) {
164 19
			$command->setCacheDuration($this->_nextCommandCacheDuration);
165 19
			$this->_nextCommandCacheDuration = null;
166 19
		}
167
168 40
		return $command;
169
	}
170
171
	/**
172
	 * Builds command from given arguments.
173
	 *
174
	 * @param string $sub_command  Command.
175
	 * @param string $param_string Parameter string.
176
	 *
177
	 * @return string
178
	 * @throws \InvalidArgumentException When command contains spaces.
179
	 */
180 41
	protected function buildCommand($sub_command, $param_string = null)
181
	{
182 41
		if ( strpos($sub_command, ' ') !== false ) {
183 1
			throw new \InvalidArgumentException('The "' . $sub_command . '" sub-command contains spaces.');
184
		}
185
186 40
		$command_line = $this->_svnCommand;
187
188 40
		if ( !empty($sub_command) ) {
189 35
			$command_line .= ' ' . $sub_command;
190 35
		}
191
192 40
		if ( !empty($param_string) ) {
193 38
			$command_line .= ' ' . $param_string;
194 38
		}
195
196 40
		$command_line = preg_replace_callback(
197 40
			'/\{([^\}]*)\}/',
198 40
			function (array $matches) {
199 32
				return escapeshellarg($matches[1]);
200 40
			},
201
			$command_line
202 40
		);
203
204 40
		return $command_line;
205
	}
206
207
	/**
208
	 * Sets cache configuration for next created command.
209
	 *
210
	 * @param mixed $cache_duration Cache duration.
211
	 *
212
	 * @return self
213
	 */
214 20
	public function withCache($cache_duration)
215
	{
216 20
		$this->_nextCommandCacheDuration = $cache_duration;
217
218 20
		return $this;
219
	}
220
221
	/**
222
	 * Returns property value.
223
	 *
224
	 * @param string $name        Property name.
225
	 * @param string $path_or_url Path to get property from.
226
	 * @param mixed  $revision    Revision.
227
	 *
228
	 * @return string
229
	 */
230 2
	public function getProperty($name, $path_or_url, $revision = null)
231
	{
232 2
		$param_string = $name . ' {' . $path_or_url . '}';
233
234 2
		if ( isset($revision) ) {
235 1
			$param_string .= ' --revision ' . $revision;
236 1
		}
237
238 2
		return $this->getCommand('propget', $param_string)->run();
239
	}
240
241
	/**
242
	 * Returns relative path of given path/url to the root of the repository.
243
	 *
244
	 * @param string $path_or_url Path or url.
245
	 *
246
	 * @return string
247
	 */
248 3
	public function getRelativePath($path_or_url)
249
	{
250 3
		$svn_info_entry = $this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION);
251
252 3
		return preg_replace(
253 3
			'/^' . preg_quote($svn_info_entry->repository->root, '/') . '/',
254 3
			'',
255 3
			(string)$svn_info_entry->url,
256
			1
257 3
		);
258
	}
259
260
	/**
261
	 * Returns repository root url from given path/url.
262
	 *
263
	 * @param string $path_or_url Path or url.
264
	 *
265
	 * @return string
266
	 */
267 3
	public function getRootUrl($path_or_url)
268
	{
269 3
		return (string)$this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION)->repository->root;
270
	}
271
272
	/**
273
	 * Determines if path is a root of the ref.
274
	 *
275
	 * @param string $path Path to a file.
276
	 *
277
	 * @return boolean
278
	 */
279 13
	public function isRefRoot($path)
280
	{
281 13
		$ref = $this->getRefByPath($path);
282
283 13
		if ( $ref === false ) {
284 4
			return false;
285
		}
286
287 9
		return preg_match('#/' . preg_quote($ref, '#') . '/$#', $path) > 0;
288
	}
289
290
	/**
291
	 * Detects ref from given path.
292
	 *
293
	 * @param string $path Path to a file.
294
	 *
295
	 * @return string|boolean
296
	 * @see    getProjectUrl
297
	 */
298 22
	public function getRefByPath($path)
299
	{
300 22
		if ( preg_match('#^.*?/(trunk|branches/[^/]+|tags/[^/]+|releases/[^/]+).*$#', $path, $regs) ) {
301 14
			return $regs[1];
302
		}
303
304 8
		return false;
305
	}
306
307
	/**
308
	 * Returns URL of the working copy.
309
	 *
310
	 * @param string $wc_path Working copy path.
311
	 *
312
	 * @return string
313
	 * @throws RepositoryCommandException When repository command failed to execute.
314
	 */
315 8
	public function getWorkingCopyUrl($wc_path)
316
	{
317 8
		if ( $this->isUrl($wc_path) ) {
318 2
			return $this->removeCredentials($wc_path);
319
		}
320
321
		try {
322 6
			$wc_url = (string)$this->_getSvnInfoEntry($wc_path, self::SVN_INFO_CACHE_DURATION)->url;
323
		}
324 6
		catch ( RepositoryCommandException $e ) {
325 3
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_UPGRADE_REQUIRED ) {
326 2
				$message = explode(PHP_EOL, $e->getMessage());
327
328 2
				$this->_io->writeln(array('', '<error>' . end($message) . '</error>', ''));
329
330 2
				if ( $this->_io->askConfirmation('Run "svn upgrade"', false) ) {
331 1
					$this->getCommand('upgrade', '{' . $wc_path . '}')->runLive();
332
333 1
					return $this->getWorkingCopyUrl($wc_path);
334
				}
335 1
			}
336
337 2
			throw $e;
338
		}
339
340 3
		return $wc_url;
341
	}
342
343
	/**
344
	 * Returns last changed revision on path/url.
345
	 *
346
	 * @param string $path_or_url Path or url.
347
	 *
348
	 * @return integer
349
	 */
350 7
	public function getLastRevision($path_or_url)
351
	{
352
		// Cache "svn info" commands to remote urls, not the working copy.
353 7
		$cache_duration = $this->isUrl($path_or_url) ? $this->_lastRevisionCacheDuration : null;
354
355 7
		return (int)$this->_getSvnInfoEntry($path_or_url, $cache_duration)->commit['revision'];
356
	}
357
358
	/**
359
	 * Determines if given path is in fact an url.
360
	 *
361
	 * @param string $path Path.
362
	 *
363
	 * @return boolean
364
	 */
365 24
	public function isUrl($path)
366
	{
367 24
		return strpos($path, '://') !== false;
368
	}
369
370
	/**
371
	 * Removes credentials from url.
372
	 *
373
	 * @param string $url URL.
374
	 *
375
	 * @return string
376
	 * @throws \InvalidArgumentException When non-url given.
377
	 */
378 15
	public function removeCredentials($url)
379
	{
380 15
		if ( !$this->isUrl($url) ) {
381 1
			throw new \InvalidArgumentException('Unable to remove credentials from "' . $url . '" path.');
382
		}
383
384 14
		return preg_replace('#^(.*)://(.*)@(.*)$#', '$1://$3', $url);
385
	}
386
387
	/**
388
	 * Returns project url (container for "trunk/branches/tags/releases" folders).
389
	 *
390
	 * @param string $repository_url Repository url.
391
	 *
392
	 * @return string
393
	 * @see    getRefByPath
394
	 */
395 9
	public function getProjectUrl($repository_url)
396
	{
397 9
		if ( preg_match('#^(.*?)/(trunk|branches|tags|releases).*$#', $repository_url, $regs) ) {
398 8
			return $regs[1];
399
		}
400
401
		// When known folder structure not detected consider, that project url was already given.
402 1
		return $repository_url;
403
	}
404
405
	/**
406
	 * Returns "svn info" entry for path or url.
407
	 *
408
	 * @param string $path_or_url    Path or url.
409
	 * @param mixed  $cache_duration Cache duration.
410
	 *
411
	 * @return \SimpleXMLElement
412
	 * @throws \LogicException When unexpected 'svn info' results retrieved.
413
	 */
414 19
	private function _getSvnInfoEntry($path_or_url, $cache_duration = null)
415
	{
416
		// Cache "svn info" commands to remote urls, not the working copy.
417 19
		if ( !isset($cache_duration) && $this->isUrl($path_or_url) ) {
418
			$cache_duration = self::SVN_INFO_CACHE_DURATION;
419
		}
420
421
		// Remove credentials from url, because "svn info" fails, when used on repository root.
422 19
		if ( $this->isUrl($path_or_url) ) {
423 10
			$path_or_url = $this->removeCredentials($path_or_url);
424 10
		}
425
426
		// TODO: When wc path (not url) is given, then credentials can be present in "svn info" result anyway.
427 19
		$svn_info = $this->withCache($cache_duration)->getCommand('info', '--xml {' . $path_or_url . '}')->run();
428
429
		// When getting remote "svn info", then path is last folder only.
430 17
		$svn_info_path = $this->_getSvnInfoEntryPath($svn_info->entry);
431
432
		// In SVN 1.7+, when doing "svn info" on repository root url.
433 17
		if ( $svn_info_path === '.' ) {
434
			$svn_info_path = $path_or_url;
435
		}
436
437 17
		if ( basename($svn_info_path) != basename($path_or_url) ) {
438 1
			throw new \LogicException('The directory "' . $path_or_url . '" not found in "svn info" command results.');
439
		}
440
441 16
		return $svn_info->entry;
442
	}
443
444
	/**
445
	 * Returns path of "svn info" entry.
446
	 *
447
	 * @param \SimpleXMLElement $svn_info_entry The "entry" node of "svn info" command.
448
	 *
449
	 * @return string
450
	 */
451 17
	private function _getSvnInfoEntryPath(\SimpleXMLElement $svn_info_entry)
452
	{
453
		// SVN 1.7+.
454 17
		$path = (string)$svn_info_entry->{'wc-info'}->{'wcroot-abspath'};
455
456 17
		if ( $path ) {
457 1
			return $path;
458
		}
459
460
		// SVN 1.6-.
461 16
		return (string)$svn_info_entry['path'];
462
	}
463
464
	/**
465
	 * Returns revision, when path was added to repository.
466
	 *
467
	 * @param string $url Url.
468
	 *
469
	 * @return integer
470
	 * @throws \InvalidArgumentException When not an url was given.
471
	 */
472
	public function getFirstRevision($url)
473
	{
474
		if ( !$this->isUrl($url) ) {
475
			throw new \InvalidArgumentException('The repository URL "' . $url . '" is invalid.');
476
		}
477
478
		$log = $this->withCache('1 year')->getCommand('log', ' -r 1:HEAD --limit 1 --xml {' . $url . '}')->run();
479
480
		return (int)$log->logentry['revision'];
481
	}
482
483
	/**
484
	 * Returns conflicts in working copy.
485
	 *
486
	 * @param string $wc_path Working copy path.
487
	 *
488
	 * @return array
489
	 */
490 2
	public function getWorkingCopyConflicts($wc_path)
491
	{
492 2
		$ret = array();
493
494 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
495 2
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_CONFLICTED) ) {
496 1
				$ret[] = $path;
497 1
			}
498 2
		}
499
500 2
		return $ret;
501
	}
502
503
	/**
504
	 * Returns missing paths in working copy.
505
	 *
506
	 * @param string $wc_path Working copy path.
507
	 *
508
	 * @return array
509
	 */
510 2
	public function getWorkingCopyMissing($wc_path)
511
	{
512 2
		$ret = array();
513
514 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
515 1
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_MISSING) ) {
516 1
				$ret[] = $path;
517 1
			}
518 2
		}
519
520 2
		return $ret;
521
	}
522
523
	/**
524
	 * Returns compact working copy status.
525
	 *
526
	 * @param string      $wc_path          Working copy path.
527
	 * @param string|null $changelist       Changelist.
528
	 * @param boolean     $with_unversioned With unversioned.
529
	 *
530
	 * @return string
531
	 */
532
	public function getCompactWorkingCopyStatus($wc_path, $changelist = null, $with_unversioned = false)
533
	{
534
		$ret = array();
535
536
		foreach ( $this->getWorkingCopyStatus($wc_path, $changelist, $with_unversioned) as $path => $status ) {
537
			$line = $this->getShortItemStatus($status['item']) . $this->getShortPropertiesStatus($status['props']);
538
			$line .= '   ' . $path;
539
540
			$ret[] = $line;
541
		}
542
543
		return implode(PHP_EOL, $ret);
544
	}
545
546
	/**
547
	 * Returns short item status.
548
	 *
549
	 * @param string $status Status.
550
	 *
551
	 * @return string
552
	 * @throws \InvalidArgumentException When unknown status given.
553
	 */
554
	protected function getShortItemStatus($status)
555
	{
556
		$status_map = array(
557
			'added' => 'A',
558
			'conflicted' => 'C',
559
			'deleted' => 'D',
560
			'external' => 'X',
561
			'ignored' => 'I',
562
			// '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...
563
			// '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...
564
			'missing' => '!',
565
			'modified' => 'M',
566
			'none' => ' ',
567
			'normal' => '_',
568
			// '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...
569
			'replaced' => 'R',
570
			'unversioned' => '?',
571
		);
572
573
		if ( !isset($status_map[$status]) ) {
574
			throw new \InvalidArgumentException('The "' . $status . '" item status is unknown.');
575
		}
576
577
		return $status_map[$status];
578
	}
579
580
	/**
581
	 * Returns short item status.
582
	 *
583
	 * @param string $status Status.
584
	 *
585
	 * @return string
586
	 * @throws \InvalidArgumentException When unknown status given.
587
	 */
588
	protected function getShortPropertiesStatus($status)
589
	{
590
		$status_map = array(
591
			'conflicted' => 'C',
592
			'modified' => 'M',
593
			'normal' => '_',
594
			'none' => ' ',
595
		);
596
597
		if ( !isset($status_map[$status]) ) {
598
			throw new \InvalidArgumentException('The "' . $status . '" properties status is unknown.');
599
		}
600
601
		return $status_map[$status];
602
	}
603
604
	/**
605
	 * Returns working copy status.
606
	 *
607
	 * @param string      $wc_path          Working copy path.
608
	 * @param string|null $changelist       Changelist.
609
	 * @param boolean     $with_unversioned With unversioned.
610
	 *
611
	 * @return array
612
	 * @throws \InvalidArgumentException When changelist doens't exist.
613
	 */
614 7
	public function getWorkingCopyStatus($wc_path, $changelist = null, $with_unversioned = false)
615
	{
616 7
		$all_paths = array();
617
618 7
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
619
620 7
		if ( !strlen($changelist) ) {
621
			// Accept all entries from "target" and "changelist" nodes.
622 5
			foreach ( $status->children() as $entries ) {
623 5
				$child_name = $entries->getName();
624
625 5
				if ( $child_name === 'target' || $child_name === 'changelist' ) {
626 4
					$all_paths += $this->processStatusEntryNodes($wc_path, $entries);
627 4
				}
628 5
			}
629 5
		}
630
		else {
631
			// Accept all entries from "changelist" node and parent folders from "target" node.
632 2
			foreach ( $status->changelist as $changelist_entries ) {
633 2
				if ( (string)$changelist_entries['name'] === $changelist ) {
634 1
					$all_paths += $this->processStatusEntryNodes($wc_path, $changelist_entries);
635 1
				}
636 2
			}
637
638 2
			if ( !$all_paths ) {
1 ignored issue
show
Bug Best Practice introduced by
The expression $all_paths of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
639 1
				throw new \InvalidArgumentException('The "' . $changelist . '" changelist doens\'t exist.');
640
			}
641
642 1
			$parent_paths = $this->getParentPaths(array_keys($all_paths));
643
644 1
			foreach ( $status->target as $target_entries ) {
645 1
				foreach ( $this->processStatusEntryNodes($wc_path, $target_entries) as $path => $path_data ) {
646 1
					if ( in_array($path, $parent_paths) ) {
647 1
						$all_paths[$path] = $path_data;
648 1
					}
649 1
				}
650 1
			}
651
652 1
			ksort($all_paths, SORT_STRING);
653
		}
654
655
		// Exclude paths, that haven't changed (e.g. from changelists).
656 6
		$changed_paths = array();
657
658 6
		foreach ( $all_paths as $path => $status ) {
659 5
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_NORMAL) ) {
660 3
				continue;
661
			}
662
663 5
			if ( !$with_unversioned && $this->isWorkingCopyPathStatus($status, self::STATUS_UNVERSIONED) ) {
664 3
				continue;
665
			}
666
667 5
			$changed_paths[$path] = $status;
668 6
		}
669
670 6
		return $changed_paths;
671
	}
672
673
	/**
674
	 * Processes "entry" nodes from "svn status" command.
675
	 *
676
	 * @param string            $wc_path Working copy path.
677
	 * @param \SimpleXMLElement $entries Entries.
678
	 *
679
	 * @return array
680
	 */
681 5
	protected function processStatusEntryNodes($wc_path, \SimpleXMLElement $entries)
682
	{
683 5
		$ret = array();
684
685 5
		foreach ( $entries as $entry ) {
686 5
			$path = (string)$entry['path'];
687 5
			$path = $path === $wc_path ? '.' : str_replace($wc_path . '/', '', $path);
688
689 5
			$ret[$path] = array(
690 5
				'item' => (string)$entry->{'wc-status'}['item'],
691 5
				'props' => (string)$entry->{'wc-status'}['props'],
692 5
				'tree-conflicted' => (string)$entry->{'wc-status'}['tree-conflicted'] === 'true',
693
			);
694 5
		}
695
696 5
		return $ret;
697
	}
698
699
	/**
700
	 * Detects specific path status.
701
	 *
702
	 * @param array  $status      Path status.
703
	 * @param string $path_status Expected path status.
704
	 *
705
	 * @return boolean
706
	 */
707 5
	protected function isWorkingCopyPathStatus(array $status, $path_status)
708
	{
709 5
		$tree_conflicted = $status['tree-conflicted'];
710
711 5
		if ( $path_status === self::STATUS_NORMAL ) {
712
			// Normal if all of 3 are normal.
713 5
			return $status['item'] === $path_status && $status['props'] === $path_status && !$tree_conflicted;
714
		}
715 5
		elseif ( $path_status === self::STATUS_CONFLICTED ) {
716
			// Conflict if any of 3 has conflict.
717 2
			return $status['item'] === $path_status || $status['props'] === $path_status || $tree_conflicted;
718
		}
719 5
		elseif ( $path_status === self::STATUS_UNVERSIONED ) {
720 5
			return $status['item'] === $path_status && $status['props'] === self::STATUS_NONE;
721
		}
722
723 1
		return $status['item'] === $path_status;
724
	}
725
726
	/**
727
	 * Returns parent paths from given paths.
728
	 *
729
	 * @param array $paths Paths.
730
	 *
731
	 * @return array
732
	 */
733 1
	protected function getParentPaths(array $paths)
734
	{
735 1
		$ret = array();
736
737 1
		foreach ( $paths as $path ) {
738 1
			while ( $path !== '.' ) {
739 1
				$path = dirname($path);
740 1
				$ret[] = $path;
741 1
			}
742 1
		}
743
744 1
		return array_unique($ret);
745
	}
746
747
	/**
748
	 * Returns working copy changelists.
749
	 *
750
	 * @param string $wc_path Working copy path.
751
	 *
752
	 * @return array
753
	 */
754 2
	public function getWorkingCopyChangelists($wc_path)
755
	{
756 2
		$ret = array();
757 2
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
758
759 2
		foreach ( $status->changelist as $changelist ) {
760 1
			$ret[] = (string)$changelist['name'];
761 2
		}
762
763 2
		sort($ret, SORT_STRING);
764
765 2
		return $ret;
766
	}
767
768
	/**
769
	 * Determines if working copy contains mixed revisions.
770
	 *
771
	 * @param string $wc_path Working copy path.
772
	 *
773
	 * @return boolean
774
	 */
775
	public function isMixedRevisionWorkingCopy($wc_path)
776
	{
777
		$revisions = array();
778
		$status = $this->getCommand('status', '--xml --verbose {' . $wc_path . '}')->run();
779
780
		foreach ( $status->target as $target ) {
781
			if ( (string)$target['path'] !== $wc_path ) {
782
				continue;
783
			}
784
785
			foreach ( $target as $entry ) {
786
				$item_status = (string)$entry->{'wc-status'}['item'];
787
788
				if ( $item_status !== self::STATUS_UNVERSIONED && $item_status !== self::STATUS_EXTERNAL ) {
789
					$revision = (int)$entry->{'wc-status'}['revision'];
790
					$revisions[$revision] = true;
791
				}
792
			}
793
		}
794
795
		// The "-1" revision happens, when external is deleted.
796
		unset($revisions[-1]);
797
798
		return count($revisions) > 1;
799
	}
800
801
	/**
802
	 * Determines if there is a working copy on a given path.
803
	 *
804
	 * @param string $path Path.
805
	 *
806
	 * @return boolean
807
	 * @throws \InvalidArgumentException When path isn't found.
808
	 * @throws RepositoryCommandException When repository command failed to execute.
809
	 */
810
	public function isWorkingCopy($path)
811
	{
812
		if ( $this->isUrl($path) || !file_exists($path) ) {
813
			throw new \InvalidArgumentException('Path "' . $path . '" not found.');
814
		}
815
816
		try {
817
			$wc_url = $this->getWorkingCopyUrl($path);
818
		}
819
		catch ( RepositoryCommandException $e ) {
820
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_NOT_WORKING_COPY ) {
821
				return false;
822
			}
823
824
			throw $e;
825
		}
826
827
		return $wc_url != '';
828
	}
829
830
}
831