Failed Conditions
Push — master ( ed2e91...da30fd )
by Alexander
02:59
created

Connector::__construct()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 23
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3

Importance

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

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

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