Completed
Push — master ( ff821a...097dbe )
by Alexander
02:39
created

Connector   F

Complexity

Total Complexity 109

Size/Duplication

Total Lines 927
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 5

Test Coverage

Coverage 82.97%

Importance

Changes 0
Metric Value
wmc 109
lcom 1
cbo 5
dl 0
loc 927
ccs 229
cts 276
cp 0.8297
rs 1.673
c 0
b 0
f 0

32 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 23 3
A prepareSvnCommand() 0 15 3
A getCommand() 0 18 2
A buildCommand() 0 26 4
A withCache() 0 6 1
A getProperty() 0 23 4
A getRelativePath() 0 11 1
A getRootUrl() 0 4 1
A isRefRoot() 0 10 2
A getRefByPath() 0 8 2
A getWorkingCopyUrl() 0 28 5
A getLastRevision() 0 7 2
A isUrl() 0 4 1
A removeCredentials() 0 8 2
A getProjectUrl() 0 9 2
B _getSvnInfoEntry() 0 39 7
A getFirstRevision() 0 10 2
A getWorkingCopyConflicts() 0 12 3
A getWorkingCopyMissing() 0 12 3
A getCompactWorkingCopyStatus() 0 19 3
A getShortItemStatus() 0 25 2
A getShortPropertiesStatus() 0 15 2
C getWorkingCopyStatus() 0 66 16
A processStatusEntryNodes() 0 18 3
B isWorkingCopyPathStatus() 0 20 10
A getParentPaths() 0 13 3
A getWorkingCopyChangelists() 0 13 2
A getWorkingCopyRevisions() 0 22 4
A isWorkingCopy() 0 19 5
B getMergedRevisionChanges() 0 40 6
A getMergedRevisions() 0 14 2
A getFileContent() 0 7 1

How to fix   Complexity   

Complex Class

Complex classes like Connector often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Connector, and based on these observations, apply Extract Interface, too.

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 102
	public function __construct(
113
		ConfigEditor $config_editor,
114
		IProcessFactory $process_factory,
115
		ConsoleIO $io,
116
		CacheManager $cache_manager,
117
		RevisionListParser $revision_list_parser
118
	) {
119 102
		$this->_configEditor = $config_editor;
120 102
		$this->_processFactory = $process_factory;
121 102
		$this->_io = $io;
122 102
		$this->_cacheManager = $cache_manager;
123 102
		$this->_revisionListParser = $revision_list_parser;
124
125 102
		$cache_duration = $this->_configEditor->get('repository-connector.last-revision-cache-duration');
126
127 102
		if ( (string)$cache_duration === '' || substr($cache_duration, 0, 1) === '0' ) {
128 4
			$cache_duration = 0;
129
		}
130
131 102
		$this->_lastRevisionCacheDuration = $cache_duration;
132
133 102
		$this->prepareSvnCommand();
134 102
	}
135
136
	/**
137
	 * Prepares static part of svn command to be used across the script.
138
	 *
139
	 * @return void
140
	 */
141 102
	protected function prepareSvnCommand()
142
	{
143 102
		$username = $this->_configEditor->get('repository-connector.username');
144 102
		$password = $this->_configEditor->get('repository-connector.password');
145
146 102
		$this->_svnCommand .= ' --non-interactive';
147
148 102
		if ( $username ) {
149 17
			$this->_svnCommand .= ' --username ' . $username;
150
		}
151
152 102
		if ( $password ) {
153 17
			$this->_svnCommand .= ' --password ' . $password;
154
		}
155 102
	}
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 56
	public function getCommand($sub_command, $param_string = null)
166
	{
167 56
		$command_line = $this->buildCommand($sub_command, $param_string);
168
169 55
		$command = new Command(
170 55
			$command_line,
171 55
			$this->_io,
172 55
			$this->_cacheManager,
173 55
			$this->_processFactory
174
		);
175
176 55
		if ( isset($this->_nextCommandCacheDuration) ) {
177 25
			$command->setCacheDuration($this->_nextCommandCacheDuration);
178 25
			$this->_nextCommandCacheDuration = null;
179
		}
180
181 55
		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 56
	protected function buildCommand($sub_command, $param_string = null)
194
	{
195 56
		if ( strpos($sub_command, ' ') !== false ) {
196 1
			throw new \InvalidArgumentException('The "' . $sub_command . '" sub-command contains spaces.');
197
		}
198
199 55
		$command_line = $this->_svnCommand;
200
201 55
		if ( !empty($sub_command) ) {
202 50
			$command_line .= ' ' . $sub_command;
203
		}
204
205 55
		if ( !empty($param_string) ) {
206 53
			$command_line .= ' ' . $param_string;
207
		}
208
209 55
		$command_line = preg_replace_callback(
210 55
			'/\{([^\}]*)\}/',
211 55
			function (array $matches) {
212 47
				return escapeshellarg($matches[1]);
213 55
			},
214 55
			$command_line
215
		);
216
217 55
		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 26
	public function withCache($cache_duration)
228
	{
229 26
		$this->_nextCommandCacheDuration = $cache_duration;
230
231 26
		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 8
	public function getProperty($name, $path_or_url, $revision = null)
245
	{
246 8
		$param_string = $name . ' {' . $path_or_url . '}';
247
248 8
		if ( isset($revision) ) {
249 7
			$param_string .= ' --revision ' . $revision;
250
		}
251
252
		// The "null" for non-existing properties is never returned, because output is converted to string.
253 8
		$property_value = '';
254
255
		try {
256 8
			$property_value = $this->getCommand('propget', $param_string)->run();
257
		}
258 1
		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 8
		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 3
			1
284
		);
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 11
	public function getWorkingCopyUrl($wc_path)
343
	{
344 11
		if ( $this->isUrl($wc_path) ) {
345 2
			return $this->removeCredentials($wc_path);
346
		}
347
348
		try {
349
			// TODO: No exception is thrown, when we have a valid cache, but SVN client was upgraded.
350 9
			$wc_url = (string)$this->_getSvnInfoEntry($wc_path, self::SVN_INFO_CACHE_DURATION)->url;
351
		}
352 4
		catch ( RepositoryCommandException $e ) {
353 3
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_UPGRADE_REQUIRED ) {
354 2
				$message = explode(PHP_EOL, $e->getMessage());
355
356 2
				$this->_io->writeln(array('', '<error>' . end($message) . '</error>', ''));
357
358 2
				if ( $this->_io->askConfirmation('Run "svn upgrade"', false) ) {
359 1
					$this->getCommand('upgrade', '{' . $wc_path . '}')->runLive();
360
361 1
					return $this->getWorkingCopyUrl($wc_path);
362
				}
363
			}
364
365 2
			throw $e;
366
		}
367
368 6
		return $wc_url;
369
	}
370
371
	/**
372
	 * Returns last changed revision on path/url.
373
	 *
374
	 * @param string $path_or_url Path or url.
375
	 *
376
	 * @return integer
377
	 */
378 9
	public function getLastRevision($path_or_url)
379
	{
380
		// Cache "svn info" commands to remote urls, not the working copy.
381 9
		$cache_duration = $this->isUrl($path_or_url) ? $this->_lastRevisionCacheDuration : null;
382
383 9
		return (int)$this->_getSvnInfoEntry($path_or_url, $cache_duration)->commit['revision'];
384
	}
385
386
	/**
387
	 * Determines if given path is in fact an url.
388
	 *
389
	 * @param string $path Path.
390
	 *
391
	 * @return boolean
392
	 */
393 29
	public function isUrl($path)
394
	{
395 29
		return strpos($path, '://') !== false;
396
	}
397
398
	/**
399
	 * Removes credentials from url.
400
	 *
401
	 * @param string $url URL.
402
	 *
403
	 * @return string
404
	 * @throws \InvalidArgumentException When non-url given.
405
	 */
406 17
	public function removeCredentials($url)
407
	{
408 17
		if ( !$this->isUrl($url) ) {
409 1
			throw new \InvalidArgumentException('Unable to remove credentials from "' . $url . '" path.');
410
		}
411
412 16
		return preg_replace('#^(.*)://(.*)@(.*)$#', '$1://$3', $url);
413
	}
414
415
	/**
416
	 * Returns project url (container for "trunk/branches/tags/releases" folders).
417
	 *
418
	 * @param string $repository_url Repository url.
419
	 *
420
	 * @return string
421
	 * @see    getRefByPath
422
	 */
423 9
	public function getProjectUrl($repository_url)
424
	{
425 9
		if ( preg_match('#^(.*?)/(trunk|branches|tags|releases).*$#', $repository_url, $regs) ) {
426 8
			return $regs[1];
427
		}
428
429
		// When known folder structure not detected consider, that project url was already given.
430 1
		return $repository_url;
431
	}
432
433
	/**
434
	 * Returns "svn info" entry for path or url.
435
	 *
436
	 * @param string $path_or_url    Path or url.
437
	 * @param mixed  $cache_duration Cache duration.
438
	 *
439
	 * @return \SimpleXMLElement
440
	 * @throws \LogicException When unexpected 'svn info' results retrieved.
441
	 */
442 24
	private function _getSvnInfoEntry($path_or_url, $cache_duration = null)
443
	{
444
		// Cache "svn info" commands to remote urls, not the working copy.
445 24
		if ( !isset($cache_duration) && $this->isUrl($path_or_url) ) {
446
			$cache_duration = self::SVN_INFO_CACHE_DURATION;
447
		}
448
449
		// Remove credentials from url, because "svn info" fails, when used on repository root.
450 24
		if ( $this->isUrl($path_or_url) ) {
451 12
			$path_or_url = $this->removeCredentials($path_or_url);
452
		}
453
454
		// Escape "@" in path names, because peg revision syntax (path@revision) isn't used in here.
455 24
		$path_or_url_escaped = $path_or_url;
456
457 24
		if ( strpos($path_or_url, '@') !== false ) {
458 1
			$path_or_url_escaped .= '@';
459
		}
460
461
		// TODO: When wc path (not url) is given, then credentials can be present in "svn info" result anyway.
462
		$svn_info = $this
463 24
			->withCache($cache_duration)
464 24
			->getCommand('info', '--xml {' . $path_or_url_escaped . '}')
465 24
			->run();
466
467
		// When getting remote "svn info", then path is last folder only.
468 22
		$svn_info_path = (string)$svn_info->entry['path'];
469
470
		// In SVN 1.7+, when doing "svn info" on repository root url.
471 22
		if ( $svn_info_path === '.' ) {
472 1
			$svn_info_path = $path_or_url;
473
		}
474
475 22
		if ( basename($svn_info_path) != basename($path_or_url) ) {
476 1
			throw new \LogicException('The directory "' . $path_or_url . '" not found in "svn info" command results.');
477
		}
478
479 21
		return $svn_info->entry;
480
	}
481
482
	/**
483
	 * Returns revision, when path was added to repository.
484
	 *
485
	 * @param string $url Url.
486
	 *
487
	 * @return integer
488
	 * @throws \InvalidArgumentException When not an url was given.
489
	 */
490
	public function getFirstRevision($url)
491
	{
492
		if ( !$this->isUrl($url) ) {
493
			throw new \InvalidArgumentException('The repository URL "' . $url . '" is invalid.');
494
		}
495
496
		$log = $this->withCache('1 year')->getCommand('log', ' -r 1:HEAD --limit 1 --xml {' . $url . '}')->run();
497
498
		return (int)$log->logentry['revision'];
499
	}
500
501
	/**
502
	 * Returns conflicts in working copy.
503
	 *
504
	 * @param string $wc_path Working copy path.
505
	 *
506
	 * @return array
507
	 */
508 2
	public function getWorkingCopyConflicts($wc_path)
509
	{
510 2
		$ret = array();
511
512 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
513 2
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_CONFLICTED) ) {
514 2
				$ret[] = $path;
515
			}
516
		}
517
518 2
		return $ret;
519
	}
520
521
	/**
522
	 * Returns missing paths in working copy.
523
	 *
524
	 * @param string $wc_path Working copy path.
525
	 *
526
	 * @return array
527
	 */
528 2
	public function getWorkingCopyMissing($wc_path)
529
	{
530 2
		$ret = array();
531
532 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
533 1
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_MISSING) ) {
534 1
				$ret[] = $path;
535
			}
536
		}
537
538 2
		return $ret;
539
	}
540
541
	/**
542
	 * Returns compact working copy status.
543
	 *
544
	 * @param string      $wc_path         Working copy path.
545
	 * @param string|null $changelist      Changelist.
546
	 * @param array       $except_statuses Except statuses.
547
	 *
548
	 * @return string
549
	 */
550
	public function getCompactWorkingCopyStatus(
551
		$wc_path,
552
		$changelist = null,
553
		array $except_statuses = array(self::STATUS_UNVERSIONED, self::STATUS_EXTERNAL)
554
	) {
555
		$ret = array();
556
557
		foreach ( $this->getWorkingCopyStatus($wc_path, $changelist, $except_statuses) as $path => $status ) {
558
			$line = $this->getShortItemStatus($status['item']); // Path status.
559
			$line .= $this->getShortPropertiesStatus($status['props']); // Properties status.
560
			$line .= ' '; // Locked status.
561
			$line .= $status['copied'] === true ? '+' : ' '; // Copied status.
562
			$line .= ' ' . $path;
563
564
			$ret[] = $line;
565
		}
566
567
		return implode(PHP_EOL, $ret);
568
	}
569
570
	/**
571
	 * Returns short item status.
572
	 *
573
	 * @param string $status Status.
574
	 *
575
	 * @return string
576
	 * @throws \InvalidArgumentException When unknown status given.
577
	 */
578
	protected function getShortItemStatus($status)
579
	{
580
		$status_map = array(
581
			'added' => 'A',
582
			'conflicted' => 'C',
583
			'deleted' => 'D',
584
			'external' => 'X',
585
			'ignored' => 'I',
586
			// '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...
587
			// '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...
588
			'missing' => '!',
589
			'modified' => 'M',
590
			'none' => ' ',
591
			'normal' => '_',
592
			// '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...
593
			'replaced' => 'R',
594
			'unversioned' => '?',
595
		);
596
597
		if ( !isset($status_map[$status]) ) {
598
			throw new \InvalidArgumentException('The "' . $status . '" item status is unknown.');
599
		}
600
601
		return $status_map[$status];
602
	}
603
604
	/**
605
	 * Returns short item status.
606
	 *
607
	 * @param string $status Status.
608
	 *
609
	 * @return string
610
	 * @throws \InvalidArgumentException When unknown status given.
611
	 */
612
	protected function getShortPropertiesStatus($status)
613
	{
614
		$status_map = array(
615
			'conflicted' => 'C',
616
			'modified' => 'M',
617
			'normal' => '_',
618
			'none' => ' ',
619
		);
620
621
		if ( !isset($status_map[$status]) ) {
622
			throw new \InvalidArgumentException('The "' . $status . '" properties status is unknown.');
623
		}
624
625
		return $status_map[$status];
626
	}
627
628
	/**
629
	 * Returns working copy status.
630
	 *
631
	 * @param string      $wc_path         Working copy path.
632
	 * @param string|null $changelist      Changelist.
633
	 * @param array       $except_statuses Except statuses.
634
	 *
635
	 * @return array
636
	 * @throws \InvalidArgumentException When changelist doens't exist.
637
	 */
638 10
	public function getWorkingCopyStatus(
639
		$wc_path,
640
		$changelist = null,
641
		array $except_statuses = array(self::STATUS_UNVERSIONED, self::STATUS_EXTERNAL)
642
	) {
643 10
		$all_paths = array();
644
645 10
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
646
647 10
		if ( !strlen($changelist) ) {
648
			// Accept all entries from "target" and "changelist" nodes.
649 8
			foreach ( $status->children() as $entries ) {
650 8
				$child_name = $entries->getName();
651
652 8
				if ( $child_name === 'target' || $child_name === 'changelist' ) {
653 8
					$all_paths += $this->processStatusEntryNodes($wc_path, $entries);
654
				}
655
			}
656
		}
657
		else {
658
			// Accept all entries from "changelist" node and parent folders from "target" node.
659 2
			foreach ( $status->changelist as $changelist_entries ) {
660 2
				if ( (string)$changelist_entries['name'] === $changelist ) {
661 2
					$all_paths += $this->processStatusEntryNodes($wc_path, $changelist_entries);
662
				}
663
			}
664
665 2
			if ( !$all_paths ) {
666 1
				throw new \InvalidArgumentException('The "' . $changelist . '" changelist doens\'t exist.');
667
			}
668
669 1
			$parent_paths = $this->getParentPaths(array_keys($all_paths));
670
671 1
			foreach ( $status->target as $target_entries ) {
672 1
				foreach ( $this->processStatusEntryNodes($wc_path, $target_entries) as $path => $path_data ) {
673 1
					if ( in_array($path, $parent_paths) ) {
674 1
						$all_paths[$path] = $path_data;
675
					}
676
				}
677
			}
678
679 1
			ksort($all_paths, SORT_STRING);
680
		}
681
682 9
		$changed_paths = array();
683
684 9
		foreach ( $all_paths as $path => $status ) {
685
			// Exclude paths, that haven't changed (e.g. from changelists).
686 8
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_NORMAL) ) {
687 6
				continue;
688
			}
689
690
			// Exclude paths with requested statuses.
691 8
			if ( $except_statuses ) {
692 7
				foreach ( $except_statuses as $except_status ) {
693 7
					if ( $this->isWorkingCopyPathStatus($status, $except_status) ) {
694 7
						continue 2;
695
					}
696
				}
697
			}
698
699 8
			$changed_paths[$path] = $status;
700
		}
701
702 9
		return $changed_paths;
703
	}
704
705
	/**
706
	 * Processes "entry" nodes from "svn status" command.
707
	 *
708
	 * @param string            $wc_path Working copy path.
709
	 * @param \SimpleXMLElement $entries Entries.
710
	 *
711
	 * @return array
712
	 */
713 8
	protected function processStatusEntryNodes($wc_path, \SimpleXMLElement $entries)
714
	{
715 8
		$ret = array();
716
717 8
		foreach ( $entries as $entry ) {
718 8
			$path = (string)$entry['path'];
719 8
			$path = $path === $wc_path ? '.' : str_replace($wc_path . '/', '', $path);
720
721 8
			$ret[$path] = array(
722 8
				'item' => (string)$entry->{'wc-status'}['item'],
723 8
				'props' => (string)$entry->{'wc-status'}['props'],
724 8
				'tree-conflicted' => (string)$entry->{'wc-status'}['tree-conflicted'] === 'true',
725 8
				'copied' => (string)$entry->{'wc-status'}['copied'] === 'true',
726
			);
727
		}
728
729 8
		return $ret;
730
	}
731
732
	/**
733
	 * Detects specific path status.
734
	 *
735
	 * @param array  $status      Path status.
736
	 * @param string $path_status Expected path status.
737
	 *
738
	 * @return boolean
739
	 */
740 8
	protected function isWorkingCopyPathStatus(array $status, $path_status)
741
	{
742 8
		$tree_conflicted = $status['tree-conflicted'];
743
744 8
		if ( $path_status === self::STATUS_NORMAL ) {
745
			// Normal if all of 3 are normal.
746 8
			return $status['item'] === $path_status
747 8
				&& ($status['props'] === $path_status || $status['props'] === self::STATUS_NONE)
748 8
				&& !$tree_conflicted;
749
		}
750 7
		elseif ( $path_status === self::STATUS_CONFLICTED ) {
751
			// Conflict if any of 3 has conflict.
752 2
			return $status['item'] === $path_status || $status['props'] === $path_status || $tree_conflicted;
753
		}
754 7
		elseif ( $path_status === self::STATUS_UNVERSIONED ) {
755 7
			return $status['item'] === $path_status && $status['props'] === self::STATUS_NONE;
756
		}
757
758 7
		return $status['item'] === $path_status;
759
	}
760
761
	/**
762
	 * Returns parent paths from given paths.
763
	 *
764
	 * @param array $paths Paths.
765
	 *
766
	 * @return array
767
	 */
768 1
	protected function getParentPaths(array $paths)
769
	{
770 1
		$ret = array();
771
772 1
		foreach ( $paths as $path ) {
773 1
			while ( $path !== '.' ) {
774 1
				$path = dirname($path);
775 1
				$ret[] = $path;
776
			}
777
		}
778
779 1
		return array_unique($ret);
780
	}
781
782
	/**
783
	 * Returns working copy changelists.
784
	 *
785
	 * @param string $wc_path Working copy path.
786
	 *
787
	 * @return array
788
	 */
789 2
	public function getWorkingCopyChangelists($wc_path)
790
	{
791 2
		$ret = array();
792 2
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
793
794 2
		foreach ( $status->changelist as $changelist ) {
795 1
			$ret[] = (string)$changelist['name'];
796
		}
797
798 2
		sort($ret, SORT_STRING);
799
800 2
		return $ret;
801
	}
802
803
	/**
804
	 * Returns revisions of paths in a working copy.
805
	 *
806
	 * @param string $wc_path Working copy path.
807
	 *
808
	 * @return array
809
	 */
810
	public function getWorkingCopyRevisions($wc_path)
811
	{
812
		$revisions = array();
813
		$status = $this->getCommand('status', '--xml --verbose {' . $wc_path . '}')->run();
814
815
		foreach ( $status->target as $target ) {
816
			if ( (string)$target['path'] !== $wc_path ) {
817
				continue;
818
			}
819
820
			foreach ( $target as $entry ) {
821
				$revision = (int)$entry->{'wc-status'}['revision'];
822
				$revisions[$revision] = true;
823
			}
824
		}
825
826
		// The "-1" revision happens, when external is deleted.
827
		// The "0" happens for not committed paths (e.g. added).
828
		unset($revisions[-1], $revisions[0]);
829
830
		return array_keys($revisions);
831
	}
832
833
	/**
834
	 * Determines if there is a working copy on a given path.
835
	 *
836
	 * @param string $path Path.
837
	 *
838
	 * @return boolean
839
	 * @throws \InvalidArgumentException When path isn't found.
840
	 * @throws RepositoryCommandException When repository command failed to execute.
841
	 */
842
	public function isWorkingCopy($path)
843
	{
844
		if ( $this->isUrl($path) || !file_exists($path) ) {
845
			throw new \InvalidArgumentException('Path "' . $path . '" not found.');
846
		}
847
848
		try {
849
			$wc_url = $this->getWorkingCopyUrl($path);
850
		}
851
		catch ( RepositoryCommandException $e ) {
852
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_NOT_WORKING_COPY ) {
853
				return false;
854
			}
855
856
			throw $e;
857
		}
858
859
		return $wc_url != '';
860
	}
861
862
	/**
863
	 * Returns list of add/removed revisions from last merge operation.
864
	 *
865
	 * @param string  $wc_path            Working copy path, where merge happens.
866
	 * @param boolean $regular_or_reverse Merge direction ("regular" or "reverse").
867
	 *
868
	 * @return array
869
	 */
870 4
	public function getMergedRevisionChanges($wc_path, $regular_or_reverse)
871
	{
872 4
		$final_paths = array();
873
874 4
		if ( $regular_or_reverse ) {
875 2
			$old_paths = $this->getMergedRevisions($wc_path, 'BASE');
876 2
			$new_paths = $this->getMergedRevisions($wc_path);
877
		}
878
		else {
879 2
			$old_paths = $this->getMergedRevisions($wc_path);
880 2
			$new_paths = $this->getMergedRevisions($wc_path, 'BASE');
881
		}
882
883 4
		if ( $old_paths === $new_paths ) {
884 2
			return array();
885
		}
886
887 2
		foreach ( $new_paths as $new_path => $new_merged_revisions ) {
888 2
			if ( !isset($old_paths[$new_path]) ) {
889
				// Merge from new path.
890 2
				$final_paths[$new_path] = $this->_revisionListParser->expandRanges(
891 2
					explode(',', $new_merged_revisions)
892
				);
893
			}
894 2
			elseif ( $new_merged_revisions != $old_paths[$new_path] ) {
895
				// Merge on existing path.
896 2
				$new_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
897 2
					explode(',', $new_merged_revisions)
898
				);
899 2
				$old_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
900 2
					explode(',', $old_paths[$new_path])
901
				);
902 2
				$final_paths[$new_path] = array_values(
903 2
					array_diff($new_merged_revisions_parsed, $old_merged_revisions_parsed)
904
				);
905
			}
906
		}
907
908 2
		return $final_paths;
909
	}
910
911
	/**
912
	 * Returns list of merged revisions per path.
913
	 *
914
	 * @param string  $wc_path  Merge target: working copy path.
915
	 * @param integer $revision Revision.
916
	 *
917
	 * @return array
918
	 */
919 4
	protected function getMergedRevisions($wc_path, $revision = null)
920
	{
921 4
		$paths = array();
922
923 4
		$merge_info = $this->getProperty('svn:mergeinfo', $wc_path, $revision);
924 4
		$merge_info = array_filter(explode("\n", $merge_info));
925
926 4
		foreach ( $merge_info as $merge_info_line ) {
927 4
			list($path, $revisions) = explode(':', $merge_info_line, 2);
928 4
			$paths[$path] = $revisions;
929
		}
930
931 4
		return $paths;
932
	}
933
934
	/**
935
	 * Returns file contents at given revision.
936
	 *
937
	 * @param string  $path_or_url Path or url.
938
	 * @param integer $revision    Revision.
939
	 *
940
	 * @return string
941
	 */
942 1
	public function getFileContent($path_or_url, $revision)
943
	{
944
		return $this
945 1
			->withCache(self::SVN_CAT_CACHE_DURATION)
946 1
			->getCommand('cat', '{' . $path_or_url . '} --revision ' . $revision)
947 1
			->run();
948
	}
949
950
}
951