Failed Conditions
Push — master ( 3d1f7c...ce06ec )
by Alexander
02:49
created

Connector::getCompactWorkingCopyStatus()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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