Completed
Push — master ( 497d3b...404e3d )
by Alexander
03:49
created

Connector::getProperty()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4.0119

Importance

Changes 0
Metric Value
dl 0
loc 23
ccs 10
cts 11
cp 0.9091
rs 8.7972
c 0
b 0
f 0
cc 4
eloc 11
nc 6
nop 3
crap 4.0119
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
	/**
46
	 * Reference to configuration.
47
	 *
48
	 * @var ConfigEditor
49
	 */
50
	private $_configEditor;
51
52
	/**
53
	 * Process factory.
54
	 *
55
	 * @var IProcessFactory
56
	 */
57
	private $_processFactory;
58
59
	/**
60
	 * Console IO.
61
	 *
62
	 * @var ConsoleIO
63
	 */
64
	private $_io;
65
66
	/**
67
	 * Cache manager.
68
	 *
69
	 * @var CacheManager
70
	 */
71
	private $_cacheManager;
72
73
	/**
74
	 * Revision list parser.
75
	 *
76
	 * @var RevisionListParser
77
	 */
78
	private $_revisionListParser;
79
80
	/**
81
	 * Path to an svn command.
82
	 *
83
	 * @var string
84
	 */
85
	private $_svnCommand = 'svn';
86
87
	/**
88
	 * Cache duration for next invoked command.
89
	 *
90
	 * @var mixed
91
	 */
92
	private $_nextCommandCacheDuration = null;
93
94
	/**
95
	 * Whatever to cache last repository revision or not.
96
	 *
97
	 * @var mixed
98
	 */
99
	private $_lastRevisionCacheDuration = null;
100
101
	/**
102
	 * Creates repository connector.
103
	 *
104
	 * @param ConfigEditor       $config_editor        ConfigEditor.
105
	 * @param IProcessFactory    $process_factory      Process factory.
106
	 * @param ConsoleIO          $io                   Console IO.
107
	 * @param CacheManager       $cache_manager        Cache manager.
108
	 * @param RevisionListParser $revision_list_parser Revision list parser.
109
	 */
110 89
	public function __construct(
111
		ConfigEditor $config_editor,
112
		IProcessFactory $process_factory,
113
		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...
114
		CacheManager $cache_manager,
115
		RevisionListParser $revision_list_parser
116
	) {
117 89
		$this->_configEditor = $config_editor;
118 89
		$this->_processFactory = $process_factory;
119 89
		$this->_io = $io;
120 89
		$this->_cacheManager = $cache_manager;
121 89
		$this->_revisionListParser = $revision_list_parser;
122
123 89
		$cache_duration = $this->_configEditor->get('repository-connector.last-revision-cache-duration');
124
125 89
		if ( (string)$cache_duration === '' || substr($cache_duration, 0, 1) === '0' ) {
126 4
			$cache_duration = 0;
127 4
		}
128
129 89
		$this->_lastRevisionCacheDuration = $cache_duration;
130
131 89
		$this->prepareSvnCommand();
132 89
	}
133
134
	/**
135
	 * Prepares static part of svn command to be used across the script.
136
	 *
137
	 * @return void
138
	 */
139 89
	protected function prepareSvnCommand()
140
	{
141 89
		$username = $this->_configEditor->get('repository-connector.username');
142 89
		$password = $this->_configEditor->get('repository-connector.password');
143
144 89
		$this->_svnCommand .= ' --non-interactive';
145
146 89
		if ( $username ) {
147 15
			$this->_svnCommand .= ' --username ' . $username;
148 15
		}
149
150 89
		if ( $password ) {
151 15
			$this->_svnCommand .= ' --password ' . $password;
152 15
		}
153 89
	}
154
155
	/**
156
	 * Builds a command.
157
	 *
158
	 * @param string      $sub_command  Sub command.
159
	 * @param string|null $param_string Parameter string.
160
	 *
161
	 * @return Command
162
	 */
163 45
	public function getCommand($sub_command, $param_string = null)
164
	{
165 45
		$command_line = $this->buildCommand($sub_command, $param_string);
166
167 44
		$command = new Command(
168 44
			$command_line,
169 44
			$this->_io,
170 44
			$this->_cacheManager,
171 44
			$this->_processFactory
172 44
		);
173
174 44
		if ( isset($this->_nextCommandCacheDuration) ) {
175 19
			$command->setCacheDuration($this->_nextCommandCacheDuration);
176 19
			$this->_nextCommandCacheDuration = null;
177 19
		}
178
179 44
		return $command;
180
	}
181
182
	/**
183
	 * Builds command from given arguments.
184
	 *
185
	 * @param string $sub_command  Command.
186
	 * @param string $param_string Parameter string.
187
	 *
188
	 * @return string
189
	 * @throws \InvalidArgumentException When command contains spaces.
190
	 */
191 45
	protected function buildCommand($sub_command, $param_string = null)
192
	{
193 45
		if ( strpos($sub_command, ' ') !== false ) {
194 1
			throw new \InvalidArgumentException('The "' . $sub_command . '" sub-command contains spaces.');
195
		}
196
197 44
		$command_line = $this->_svnCommand;
198
199 44
		if ( !empty($sub_command) ) {
200 39
			$command_line .= ' ' . $sub_command;
201 39
		}
202
203 44
		if ( !empty($param_string) ) {
204 42
			$command_line .= ' ' . $param_string;
205 42
		}
206
207 44
		$command_line = preg_replace_callback(
208 44
			'/\{([^\}]*)\}/',
209 44
			function (array $matches) {
210 36
				return escapeshellarg($matches[1]);
211 44
			},
212
			$command_line
213 44
		);
214
215 44
		return $command_line;
216
	}
217
218
	/**
219
	 * Sets cache configuration for next created command.
220
	 *
221
	 * @param mixed $cache_duration Cache duration.
222
	 *
223
	 * @return self
224
	 */
225 20
	public function withCache($cache_duration)
226
	{
227 20
		$this->_nextCommandCacheDuration = $cache_duration;
228
229 20
		return $this;
230
	}
231
232
	/**
233
	 * Returns property value.
234
	 *
235
	 * @param string $name        Property name.
236
	 * @param string $path_or_url Path to get property from.
237
	 * @param mixed  $revision    Revision.
238
	 *
239
	 * @return string
240
	 * @throws RepositoryCommandException When other, then missing property exception happens.
241
	 */
242 6
	public function getProperty($name, $path_or_url, $revision = null)
243
	{
244 6
		$param_string = $name . ' {' . $path_or_url . '}';
245
246 6
		if ( isset($revision) ) {
247 5
			$param_string .= ' --revision ' . $revision;
248 5
		}
249
250
		// The "null" for non-existing properties is never returned, because output is converted to string.
251 6
		$property_value = '';
252
253
		try {
254 6
			$property_value = $this->getCommand('propget', $param_string)->run();
255
		}
256 6
		catch ( RepositoryCommandException $e ) {
257
			// Preserve SVN 1.8- behavior, where reading value of non-existing property returned an empty string.
258 1
			if ( $e->getCode() !== RepositoryCommandException::SVN_ERR_BASE ) {
259
				throw $e;
260
			}
261
		}
262
263 6
		return $property_value;
264
	}
265
266
	/**
267
	 * Returns relative path of given path/url to the root of the repository.
268
	 *
269
	 * @param string $path_or_url Path or url.
270
	 *
271
	 * @return string
272
	 */
273 3
	public function getRelativePath($path_or_url)
274
	{
275 3
		$svn_info_entry = $this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION);
276
277 3
		return preg_replace(
278 3
			'/^' . preg_quote($svn_info_entry->repository->root, '/') . '/',
279 3
			'',
280 3
			(string)$svn_info_entry->url,
281
			1
282 3
		);
283
	}
284
285
	/**
286
	 * Returns repository root url from given path/url.
287
	 *
288
	 * @param string $path_or_url Path or url.
289
	 *
290
	 * @return string
291
	 */
292 3
	public function getRootUrl($path_or_url)
293
	{
294 3
		return (string)$this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION)->repository->root;
295
	}
296
297
	/**
298
	 * Determines if path is a root of the ref.
299
	 *
300
	 * @param string $path Path to a file.
301
	 *
302
	 * @return boolean
303
	 */
304 13
	public function isRefRoot($path)
305
	{
306 13
		$ref = $this->getRefByPath($path);
307
308 13
		if ( $ref === false ) {
309 4
			return false;
310
		}
311
312 9
		return preg_match('#/' . preg_quote($ref, '#') . '/$#', $path) > 0;
313
	}
314
315
	/**
316
	 * Detects ref from given path.
317
	 *
318
	 * @param string $path Path to a file.
319
	 *
320
	 * @return string|boolean
321
	 * @see    getProjectUrl
322
	 */
323 22
	public function getRefByPath($path)
324
	{
325 22
		if ( preg_match('#^.*?/(trunk|branches/[^/]+|tags/[^/]+|releases/[^/]+).*$#', $path, $regs) ) {
326 14
			return $regs[1];
327
		}
328
329 8
		return false;
330
	}
331
332
	/**
333
	 * Returns URL of the working copy.
334
	 *
335
	 * @param string $wc_path Working copy path.
336
	 *
337
	 * @return string
338
	 * @throws RepositoryCommandException When repository command failed to execute.
339
	 */
340 8
	public function getWorkingCopyUrl($wc_path)
341
	{
342 8
		if ( $this->isUrl($wc_path) ) {
343 2
			return $this->removeCredentials($wc_path);
344
		}
345
346
		try {
347 6
			$wc_url = (string)$this->_getSvnInfoEntry($wc_path, self::SVN_INFO_CACHE_DURATION)->url;
348
		}
349 6
		catch ( RepositoryCommandException $e ) {
350 3
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_UPGRADE_REQUIRED ) {
351 2
				$message = explode(PHP_EOL, $e->getMessage());
352
353 2
				$this->_io->writeln(array('', '<error>' . end($message) . '</error>', ''));
354
355 2
				if ( $this->_io->askConfirmation('Run "svn upgrade"', false) ) {
356 1
					$this->getCommand('upgrade', '{' . $wc_path . '}')->runLive();
357
358 1
					return $this->getWorkingCopyUrl($wc_path);
359
				}
360 1
			}
361
362 2
			throw $e;
363
		}
364
365 3
		return $wc_url;
366
	}
367
368
	/**
369
	 * Returns last changed revision on path/url.
370
	 *
371
	 * @param string $path_or_url Path or url.
372
	 *
373
	 * @return integer
374
	 */
375 7
	public function getLastRevision($path_or_url)
376
	{
377
		// Cache "svn info" commands to remote urls, not the working copy.
378 7
		$cache_duration = $this->isUrl($path_or_url) ? $this->_lastRevisionCacheDuration : null;
379
380 7
		return (int)$this->_getSvnInfoEntry($path_or_url, $cache_duration)->commit['revision'];
381
	}
382
383
	/**
384
	 * Determines if given path is in fact an url.
385
	 *
386
	 * @param string $path Path.
387
	 *
388
	 * @return boolean
389
	 */
390 24
	public function isUrl($path)
391
	{
392 24
		return strpos($path, '://') !== false;
393
	}
394
395
	/**
396
	 * Removes credentials from url.
397
	 *
398
	 * @param string $url URL.
399
	 *
400
	 * @return string
401
	 * @throws \InvalidArgumentException When non-url given.
402
	 */
403 15
	public function removeCredentials($url)
404
	{
405 15
		if ( !$this->isUrl($url) ) {
406 1
			throw new \InvalidArgumentException('Unable to remove credentials from "' . $url . '" path.');
407
		}
408
409 14
		return preg_replace('#^(.*)://(.*)@(.*)$#', '$1://$3', $url);
410
	}
411
412
	/**
413
	 * Returns project url (container for "trunk/branches/tags/releases" folders).
414
	 *
415
	 * @param string $repository_url Repository url.
416
	 *
417
	 * @return string
418
	 * @see    getRefByPath
419
	 */
420 9
	public function getProjectUrl($repository_url)
421
	{
422 9
		if ( preg_match('#^(.*?)/(trunk|branches|tags|releases).*$#', $repository_url, $regs) ) {
423 8
			return $regs[1];
424
		}
425
426
		// When known folder structure not detected consider, that project url was already given.
427 1
		return $repository_url;
428
	}
429
430
	/**
431
	 * Returns "svn info" entry for path or url.
432
	 *
433
	 * @param string $path_or_url    Path or url.
434
	 * @param mixed  $cache_duration Cache duration.
435
	 *
436
	 * @return \SimpleXMLElement
437
	 * @throws \LogicException When unexpected 'svn info' results retrieved.
438
	 */
439 19
	private function _getSvnInfoEntry($path_or_url, $cache_duration = null)
440
	{
441
		// Cache "svn info" commands to remote urls, not the working copy.
442 19
		if ( !isset($cache_duration) && $this->isUrl($path_or_url) ) {
443
			$cache_duration = self::SVN_INFO_CACHE_DURATION;
444
		}
445
446
		// Remove credentials from url, because "svn info" fails, when used on repository root.
447 19
		if ( $this->isUrl($path_or_url) ) {
448 10
			$path_or_url = $this->removeCredentials($path_or_url);
449 10
		}
450
451
		// TODO: When wc path (not url) is given, then credentials can be present in "svn info" result anyway.
452 19
		$svn_info = $this->withCache($cache_duration)->getCommand('info', '--xml {' . $path_or_url . '}')->run();
453
454
		// When getting remote "svn info", then path is last folder only.
455 17
		$svn_info_path = $this->_getSvnInfoEntryPath($svn_info->entry);
456
457
		// In SVN 1.7+, when doing "svn info" on repository root url.
458 17
		if ( $svn_info_path === '.' ) {
459
			$svn_info_path = $path_or_url;
460
		}
461
462 17
		if ( basename($svn_info_path) != basename($path_or_url) ) {
463 1
			throw new \LogicException('The directory "' . $path_or_url . '" not found in "svn info" command results.');
464
		}
465
466 16
		return $svn_info->entry;
467
	}
468
469
	/**
470
	 * Returns path of "svn info" entry.
471
	 *
472
	 * @param \SimpleXMLElement $svn_info_entry The "entry" node of "svn info" command.
473
	 *
474
	 * @return string
475
	 */
476 17
	private function _getSvnInfoEntryPath(\SimpleXMLElement $svn_info_entry)
477
	{
478
		// SVN 1.7+.
479 17
		$path = (string)$svn_info_entry->{'wc-info'}->{'wcroot-abspath'};
480
481 17
		if ( $path ) {
482 1
			return $path;
483
		}
484
485
		// SVN 1.6-.
486 16
		return (string)$svn_info_entry['path'];
487
	}
488
489
	/**
490
	 * Returns revision, when path was added to repository.
491
	 *
492
	 * @param string $url Url.
493
	 *
494
	 * @return integer
495
	 * @throws \InvalidArgumentException When not an url was given.
496
	 */
497
	public function getFirstRevision($url)
498
	{
499
		if ( !$this->isUrl($url) ) {
500
			throw new \InvalidArgumentException('The repository URL "' . $url . '" is invalid.');
501
		}
502
503
		$log = $this->withCache('1 year')->getCommand('log', ' -r 1:HEAD --limit 1 --xml {' . $url . '}')->run();
504
505
		return (int)$log->logentry['revision'];
506
	}
507
508
	/**
509
	 * Returns conflicts in working copy.
510
	 *
511
	 * @param string $wc_path Working copy path.
512
	 *
513
	 * @return array
514
	 */
515 2
	public function getWorkingCopyConflicts($wc_path)
516
	{
517 2
		$ret = array();
518
519 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
520 2
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_CONFLICTED) ) {
521 1
				$ret[] = $path;
522 1
			}
523 2
		}
524
525 2
		return $ret;
526
	}
527
528
	/**
529
	 * Returns missing paths in working copy.
530
	 *
531
	 * @param string $wc_path Working copy path.
532
	 *
533
	 * @return array
534
	 */
535 2
	public function getWorkingCopyMissing($wc_path)
536
	{
537 2
		$ret = array();
538
539 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
540 1
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_MISSING) ) {
541 1
				$ret[] = $path;
542 1
			}
543 2
		}
544
545 2
		return $ret;
546
	}
547
548
	/**
549
	 * Returns compact working copy status.
550
	 *
551
	 * @param string      $wc_path          Working copy path.
552
	 * @param string|null $changelist       Changelist.
553
	 * @param boolean     $with_unversioned With unversioned.
554
	 *
555
	 * @return string
556
	 */
557
	public function getCompactWorkingCopyStatus($wc_path, $changelist = null, $with_unversioned = false)
558
	{
559
		$ret = array();
560
561
		foreach ( $this->getWorkingCopyStatus($wc_path, $changelist, $with_unversioned) as $path => $status ) {
562
			$line = $this->getShortItemStatus($status['item']) . $this->getShortPropertiesStatus($status['props']);
563
			$line .= '   ' . $path;
564
565
			$ret[] = $line;
566
		}
567
568
		return implode(PHP_EOL, $ret);
569
	}
570
571
	/**
572
	 * Returns short item status.
573
	 *
574
	 * @param string $status Status.
575
	 *
576
	 * @return string
577
	 * @throws \InvalidArgumentException When unknown status given.
578
	 */
579
	protected function getShortItemStatus($status)
580
	{
581
		$status_map = array(
582
			'added' => 'A',
583
			'conflicted' => 'C',
584
			'deleted' => 'D',
585
			'external' => 'X',
586
			'ignored' => 'I',
587
			// '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...
588
			// '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...
589
			'missing' => '!',
590
			'modified' => 'M',
591
			'none' => ' ',
592
			'normal' => '_',
593
			// '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...
594
			'replaced' => 'R',
595
			'unversioned' => '?',
596
		);
597
598
		if ( !isset($status_map[$status]) ) {
599
			throw new \InvalidArgumentException('The "' . $status . '" item status is unknown.');
600
		}
601
602
		return $status_map[$status];
603
	}
604
605
	/**
606
	 * Returns short item status.
607
	 *
608
	 * @param string $status Status.
609
	 *
610
	 * @return string
611
	 * @throws \InvalidArgumentException When unknown status given.
612
	 */
613
	protected function getShortPropertiesStatus($status)
614
	{
615
		$status_map = array(
616
			'conflicted' => 'C',
617
			'modified' => 'M',
618
			'normal' => '_',
619
			'none' => ' ',
620
		);
621
622
		if ( !isset($status_map[$status]) ) {
623
			throw new \InvalidArgumentException('The "' . $status . '" properties status is unknown.');
624
		}
625
626
		return $status_map[$status];
627
	}
628
629
	/**
630
	 * Returns working copy status.
631
	 *
632
	 * @param string      $wc_path          Working copy path.
633
	 * @param string|null $changelist       Changelist.
634
	 * @param boolean     $with_unversioned With unversioned.
635
	 *
636
	 * @return array
637
	 * @throws \InvalidArgumentException When changelist doens't exist.
638
	 */
639 7
	public function getWorkingCopyStatus($wc_path, $changelist = null, $with_unversioned = false)
640
	{
641 7
		$all_paths = array();
642
643 7
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
644
645 7
		if ( !strlen($changelist) ) {
646
			// Accept all entries from "target" and "changelist" nodes.
647 5
			foreach ( $status->children() as $entries ) {
648 5
				$child_name = $entries->getName();
649
650 5
				if ( $child_name === 'target' || $child_name === 'changelist' ) {
651 4
					$all_paths += $this->processStatusEntryNodes($wc_path, $entries);
652 4
				}
653 5
			}
654 5
		}
655
		else {
656
			// Accept all entries from "changelist" node and parent folders from "target" node.
657 2
			foreach ( $status->changelist as $changelist_entries ) {
658 2
				if ( (string)$changelist_entries['name'] === $changelist ) {
659 1
					$all_paths += $this->processStatusEntryNodes($wc_path, $changelist_entries);
660 1
				}
661 2
			}
662
663 2
			if ( !$all_paths ) {
664 1
				throw new \InvalidArgumentException('The "' . $changelist . '" changelist doens\'t exist.');
665
			}
666
667 1
			$parent_paths = $this->getParentPaths(array_keys($all_paths));
668
669 1
			foreach ( $status->target as $target_entries ) {
670 1
				foreach ( $this->processStatusEntryNodes($wc_path, $target_entries) as $path => $path_data ) {
671 1
					if ( in_array($path, $parent_paths) ) {
672 1
						$all_paths[$path] = $path_data;
673 1
					}
674 1
				}
675 1
			}
676
677 1
			ksort($all_paths, SORT_STRING);
678
		}
679
680
		// Exclude paths, that haven't changed (e.g. from changelists).
681 6
		$changed_paths = array();
682
683 6
		foreach ( $all_paths as $path => $status ) {
684 5
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_NORMAL) ) {
685 3
				continue;
686
			}
687
688 5
			if ( !$with_unversioned && $this->isWorkingCopyPathStatus($status, self::STATUS_UNVERSIONED) ) {
689 3
				continue;
690
			}
691
692 5
			$changed_paths[$path] = $status;
693 6
		}
694
695 6
		return $changed_paths;
696
	}
697
698
	/**
699
	 * Processes "entry" nodes from "svn status" command.
700
	 *
701
	 * @param string            $wc_path Working copy path.
702
	 * @param \SimpleXMLElement $entries Entries.
703
	 *
704
	 * @return array
705
	 */
706 5
	protected function processStatusEntryNodes($wc_path, \SimpleXMLElement $entries)
707
	{
708 5
		$ret = array();
709
710 5
		foreach ( $entries as $entry ) {
711 5
			$path = (string)$entry['path'];
712 5
			$path = $path === $wc_path ? '.' : str_replace($wc_path . '/', '', $path);
713
714 5
			$ret[$path] = array(
715 5
				'item' => (string)$entry->{'wc-status'}['item'],
716 5
				'props' => (string)$entry->{'wc-status'}['props'],
717 5
				'tree-conflicted' => (string)$entry->{'wc-status'}['tree-conflicted'] === 'true',
718
			);
719 5
		}
720
721 5
		return $ret;
722
	}
723
724
	/**
725
	 * Detects specific path status.
726
	 *
727
	 * @param array  $status      Path status.
728
	 * @param string $path_status Expected path status.
729
	 *
730
	 * @return boolean
731
	 */
732 5
	protected function isWorkingCopyPathStatus(array $status, $path_status)
733
	{
734 5
		$tree_conflicted = $status['tree-conflicted'];
735
736 5
		if ( $path_status === self::STATUS_NORMAL ) {
737
			// Normal if all of 3 are normal.
738 5
			return $status['item'] === $path_status && $status['props'] === $path_status && !$tree_conflicted;
739
		}
740 5
		elseif ( $path_status === self::STATUS_CONFLICTED ) {
741
			// Conflict if any of 3 has conflict.
742 2
			return $status['item'] === $path_status || $status['props'] === $path_status || $tree_conflicted;
743
		}
744 5
		elseif ( $path_status === self::STATUS_UNVERSIONED ) {
745 5
			return $status['item'] === $path_status && $status['props'] === self::STATUS_NONE;
746
		}
747
748 1
		return $status['item'] === $path_status;
749
	}
750
751
	/**
752
	 * Returns parent paths from given paths.
753
	 *
754
	 * @param array $paths Paths.
755
	 *
756
	 * @return array
757
	 */
758 1
	protected function getParentPaths(array $paths)
759
	{
760 1
		$ret = array();
761
762 1
		foreach ( $paths as $path ) {
763 1
			while ( $path !== '.' ) {
764 1
				$path = dirname($path);
765 1
				$ret[] = $path;
766 1
			}
767 1
		}
768
769 1
		return array_unique($ret);
770
	}
771
772
	/**
773
	 * Returns working copy changelists.
774
	 *
775
	 * @param string $wc_path Working copy path.
776
	 *
777
	 * @return array
778
	 */
779 2
	public function getWorkingCopyChangelists($wc_path)
780
	{
781 2
		$ret = array();
782 2
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
783
784 2
		foreach ( $status->changelist as $changelist ) {
785 1
			$ret[] = (string)$changelist['name'];
786 2
		}
787
788 2
		sort($ret, SORT_STRING);
789
790 2
		return $ret;
791
	}
792
793
	/**
794
	 * Returns revisions of paths in a working copy.
795
	 *
796
	 * @param string $wc_path Working copy path.
797
	 *
798
	 * @return array
799
	 */
800
	public function getWorkingCopyRevisions($wc_path)
801
	{
802
		$revisions = array();
803
		$status = $this->getCommand('status', '--xml --verbose {' . $wc_path . '}')->run();
804
805
		foreach ( $status->target as $target ) {
806
			if ( (string)$target['path'] !== $wc_path ) {
807
				continue;
808
			}
809
810
			foreach ( $target as $entry ) {
811
				$revision = (int)$entry->{'wc-status'}['revision'];
812
				$revisions[$revision] = true;
813
			}
814
		}
815
816
		// The "-1" revision happens, when external is deleted.
817
		// The "0" happens for not committed paths (e.g. added).
818
		unset($revisions[-1], $revisions[0]);
819
820
		return array_keys($revisions);
821
	}
822
823
	/**
824
	 * Determines if there is a working copy on a given path.
825
	 *
826
	 * @param string $path Path.
827
	 *
828
	 * @return boolean
829
	 * @throws \InvalidArgumentException When path isn't found.
830
	 * @throws RepositoryCommandException When repository command failed to execute.
831
	 */
832
	public function isWorkingCopy($path)
833
	{
834
		if ( $this->isUrl($path) || !file_exists($path) ) {
835
			throw new \InvalidArgumentException('Path "' . $path . '" not found.');
836
		}
837
838
		try {
839
			$wc_url = $this->getWorkingCopyUrl($path);
840
		}
841
		catch ( RepositoryCommandException $e ) {
842
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_NOT_WORKING_COPY ) {
843
				return false;
844
			}
845
846
			throw $e;
847
		}
848
849
		return $wc_url != '';
850
	}
851
852
	/**
853
	 * Returns list of just merged revisions.
854
	 *
855
	 * @param string $wc_path Working copy path, where merge happens.
856
	 *
857
	 * @return array
858
	 */
859 2
	public function getFreshMergedRevisions($wc_path)
860
	{
861 2
		$final_paths = array();
862 2
		$old_paths = $this->getMergedRevisions($wc_path, 'BASE');
863 2
		$new_paths = $this->getMergedRevisions($wc_path);
864
865 2
		if ( $old_paths === $new_paths ) {
866 1
			return array();
867
		}
868
869 1
		foreach ( $new_paths as $new_path => $new_merged_revisions ) {
870 1
			if ( !isset($old_paths[$new_path]) ) {
871
				// Merge from new path.
872 1
				$final_paths[$new_path] = $this->_revisionListParser->expandRanges(
873 1
					explode(',', $new_merged_revisions)
874 1
				);
875 1
			}
876 1
			elseif ( $new_merged_revisions != $old_paths[$new_path] ) {
877
				// Merge on existing path.
878 1
				$new_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
879 1
					explode(',', $new_merged_revisions)
880 1
				);
881 1
				$old_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
882 1
					explode(',', $old_paths[$new_path])
883 1
				);
884 1
				$final_paths[$new_path] = array_values(
885 1
					array_diff($new_merged_revisions_parsed, $old_merged_revisions_parsed)
886 1
				);
887 1
			}
888 1
		}
889
890 1
		return $final_paths;
891
	}
892
893
	/**
894
	 * Returns list of merged revisions per path.
895
	 *
896
	 * @param string  $wc_path  Merge target: working copy path.
897
	 * @param integer $revision Revision.
898
	 *
899
	 * @return array
900
	 */
901 2
	protected function getMergedRevisions($wc_path, $revision = null)
902
	{
903 2
		$paths = array();
904
905 2
		$merge_info = $this->getProperty('svn:mergeinfo', $wc_path, $revision);
906 2
		$merge_info = array_filter(explode("\n", $merge_info));
907
908 2
		foreach ( $merge_info as $merge_info_line ) {
909 2
			list($path, $revisions) = explode(':', $merge_info_line, 2);
910 2
			$paths[$path] = $revisions;
911 2
		}
912
913 2
		return $paths;
914
	}
915
916
}
917