Completed
Pull Request — master (#114)
by Alexander
07:00
created

Connector::getLastRevision()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 7
ccs 3
cts 3
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 3
nc 2
nop 1
crap 2
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 95
	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 95
		$this->_configEditor = $config_editor;
120 95
		$this->_processFactory = $process_factory;
121 95
		$this->_io = $io;
122 95
		$this->_cacheManager = $cache_manager;
123 95
		$this->_revisionListParser = $revision_list_parser;
124
125 95
		$cache_duration = $this->_configEditor->get('repository-connector.last-revision-cache-duration');
126
127 95
		if ( (string)$cache_duration === '' || substr($cache_duration, 0, 1) === '0' ) {
128 4
			$cache_duration = 0;
129
		}
130
131 95
		$this->_lastRevisionCacheDuration = $cache_duration;
132
133 95
		$this->prepareSvnCommand();
134 95
	}
135
136
	/**
137
	 * Prepares static part of svn command to be used across the script.
138
	 *
139
	 * @return void
140
	 */
141 95
	protected function prepareSvnCommand()
142
	{
143 95
		$username = $this->_configEditor->get('repository-connector.username');
144 95
		$password = $this->_configEditor->get('repository-connector.password');
145
146 95
		$this->_svnCommand .= ' --non-interactive';
147
148 95
		if ( $username ) {
149 17
			$this->_svnCommand .= ' --username ' . $username;
150
		}
151
152 95
		if ( $password ) {
153 17
			$this->_svnCommand .= ' --password ' . $password;
154
		}
155 95
	}
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 51
	public function getCommand($sub_command, $param_string = null)
166
	{
167 51
		$command_line = $this->buildCommand($sub_command, $param_string);
168
169 50
		$command = new Command(
170 50
			$command_line,
171 50
			$this->_io,
172 50
			$this->_cacheManager,
173 50
			$this->_processFactory
174
		);
175
176 50
		if ( isset($this->_nextCommandCacheDuration) ) {
177 24
			$command->setCacheDuration($this->_nextCommandCacheDuration);
178 24
			$this->_nextCommandCacheDuration = null;
179
		}
180
181 50
		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 51
	protected function buildCommand($sub_command, $param_string = null)
194
	{
195 51
		if ( strpos($sub_command, ' ') !== false ) {
196 1
			throw new \InvalidArgumentException('The "' . $sub_command . '" sub-command contains spaces.');
197
		}
198
199 50
		$command_line = $this->_svnCommand;
200
201 50
		if ( !empty($sub_command) ) {
202 45
			$command_line .= ' ' . $sub_command;
203
		}
204
205 50
		if ( !empty($param_string) ) {
206 48
			$command_line .= ' ' . $param_string;
207
		}
208
209 50
		$command_line = preg_replace_callback(
210 50
			'/\{([^\}]*)\}/',
211 50
			function (array $matches) {
212 42
				return escapeshellarg($matches[1]);
213 50
			},
214 50
			$command_line
215
		);
216
217 50
		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 25
	public function withCache($cache_duration)
228
	{
229 25
		$this->_nextCommandCacheDuration = $cache_duration;
230
231 25
		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
		}
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 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 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 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 10
	public function getWorkingCopyUrl($wc_path)
343
	{
344 10
		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 8
			$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 5
		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 28
	public function isUrl($path)
394
	{
395 28
		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 23
	private function _getSvnInfoEntry($path_or_url, $cache_duration = null)
443
	{
444
		// Cache "svn info" commands to remote urls, not the working copy.
445 23
		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 23
		if ( $this->isUrl($path_or_url) ) {
451 12
			$path_or_url = $this->removeCredentials($path_or_url);
452
		}
453
454
		// TODO: When wc path (not url) is given, then credentials can be present in "svn info" result anyway.
455 23
		$svn_info = $this->withCache($cache_duration)->getCommand('info', '--xml {' . $path_or_url . '}')->run();
456
457
		// When getting remote "svn info", then path is last folder only.
458 21
		$svn_info_path = (string)$svn_info->entry['path'];
459
460
		// In SVN 1.7+, when doing "svn info" on repository root url.
461 21
		if ( $svn_info_path === '.' ) {
462 1
			$svn_info_path = $path_or_url;
463
		}
464
465 21
		if ( basename($svn_info_path) != basename($path_or_url) ) {
466 1
			throw new \LogicException('The directory "' . $path_or_url . '" not found in "svn info" command results.');
467
		}
468
469 20
		return $svn_info->entry;
470
	}
471
472
	/**
473
	 * Returns revision, when path was added to repository.
474
	 *
475
	 * @param string $url Url.
476
	 *
477
	 * @return integer
478
	 * @throws \InvalidArgumentException When not an url was given.
479
	 */
480
	public function getFirstRevision($url)
481
	{
482
		if ( !$this->isUrl($url) ) {
483
			throw new \InvalidArgumentException('The repository URL "' . $url . '" is invalid.');
484
		}
485
486
		$log = $this->withCache('1 year')->getCommand('log', ' -r 1:HEAD --limit 1 --xml {' . $url . '}')->run();
487
488
		return (int)$log->logentry['revision'];
489
	}
490
491
	/**
492
	 * Returns conflicts in working copy.
493
	 *
494
	 * @param string $wc_path Working copy path.
495
	 *
496
	 * @return array
497
	 */
498 2
	public function getWorkingCopyConflicts($wc_path)
499
	{
500 2
		$ret = array();
501
502 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
503 2
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_CONFLICTED) ) {
504 2
				$ret[] = $path;
505
			}
506
		}
507
508 2
		return $ret;
509
	}
510
511
	/**
512
	 * Returns missing paths in working copy.
513
	 *
514
	 * @param string $wc_path Working copy path.
515
	 *
516
	 * @return array
517
	 */
518 2
	public function getWorkingCopyMissing($wc_path)
519
	{
520 2
		$ret = array();
521
522 2
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
523 1
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_MISSING) ) {
524 1
				$ret[] = $path;
525
			}
526
		}
527
528 2
		return $ret;
529
	}
530
531
	/**
532
	 * Returns compact working copy status.
533
	 *
534
	 * @param string      $wc_path         Working copy path.
535
	 * @param string|null $changelist      Changelist.
536
	 * @param array       $except_statuses Except statuses.
537
	 *
538
	 * @return string
539
	 */
540
	public function getCompactWorkingCopyStatus(
541
		$wc_path,
542
		$changelist = null,
543
		array $except_statuses = array(self::STATUS_UNVERSIONED, self::STATUS_EXTERNAL)
544
	) {
545
		$ret = array();
546
547
		foreach ( $this->getWorkingCopyStatus($wc_path, $changelist, $except_statuses) 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 array       $except_statuses Except statuses.
621
	 *
622
	 * @return array
623
	 * @throws \InvalidArgumentException When changelist doens't exist.
624
	 */
625 8
	public function getWorkingCopyStatus(
626
		$wc_path,
627
		$changelist = null,
628
		array $except_statuses = array(self::STATUS_UNVERSIONED, self::STATUS_EXTERNAL)
629
	) {
630 8
		$all_paths = array();
631
632 8
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
633
634 8
		if ( !strlen($changelist) ) {
635
			// Accept all entries from "target" and "changelist" nodes.
636 6
			foreach ( $status->children() as $entries ) {
637 6
				$child_name = $entries->getName();
638
639 6
				if ( $child_name === 'target' || $child_name === 'changelist' ) {
640 6
					$all_paths += $this->processStatusEntryNodes($wc_path, $entries);
641
				}
642
			}
643
		}
644
		else {
645
			// Accept all entries from "changelist" node and parent folders from "target" node.
646 2
			foreach ( $status->changelist as $changelist_entries ) {
647 2
				if ( (string)$changelist_entries['name'] === $changelist ) {
648 2
					$all_paths += $this->processStatusEntryNodes($wc_path, $changelist_entries);
649
				}
650
			}
651
652 2
			if ( !$all_paths ) {
653 1
				throw new \InvalidArgumentException('The "' . $changelist . '" changelist doens\'t exist.');
654
			}
655
656 1
			$parent_paths = $this->getParentPaths(array_keys($all_paths));
657
658 1
			foreach ( $status->target as $target_entries ) {
659 1
				foreach ( $this->processStatusEntryNodes($wc_path, $target_entries) as $path => $path_data ) {
660 1
					if ( in_array($path, $parent_paths) ) {
661 1
						$all_paths[$path] = $path_data;
662
					}
663
				}
664
			}
665
666 1
			ksort($all_paths, SORT_STRING);
667
		}
668
669 7
		$changed_paths = array();
670
671 7
		foreach ( $all_paths as $path => $status ) {
672
			// Exclude paths, that haven't changed (e.g. from changelists).
673 6
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_NORMAL) ) {
674 4
				continue;
675
			}
676
677
			// Exclude paths with requested statuses.
678 6
			if ( $except_statuses ) {
679 5
				foreach ( $except_statuses as $except_status ) {
680 5
					if ( $this->isWorkingCopyPathStatus($status, $except_status) ) {
681 5
						continue 2;
682
					}
683
				}
684
			}
685
686 6
			$changed_paths[$path] = $status;
687
		}
688
689 7
		return $changed_paths;
690
	}
691
692
	/**
693
	 * Processes "entry" nodes from "svn status" command.
694
	 *
695
	 * @param string            $wc_path Working copy path.
696
	 * @param \SimpleXMLElement $entries Entries.
697
	 *
698
	 * @return array
699
	 */
700 6
	protected function processStatusEntryNodes($wc_path, \SimpleXMLElement $entries)
701
	{
702 6
		$ret = array();
703
704 6
		foreach ( $entries as $entry ) {
705 6
			$path = (string)$entry['path'];
706 6
			$path = $path === $wc_path ? '.' : str_replace($wc_path . '/', '', $path);
707
708 6
			$ret[$path] = array(
709 6
				'item' => (string)$entry->{'wc-status'}['item'],
710 6
				'props' => (string)$entry->{'wc-status'}['props'],
711 6
				'tree-conflicted' => (string)$entry->{'wc-status'}['tree-conflicted'] === 'true',
712
			);
713
		}
714
715 6
		return $ret;
716
	}
717
718
	/**
719
	 * Detects specific path status.
720
	 *
721
	 * @param array  $status      Path status.
722
	 * @param string $path_status Expected path status.
723
	 *
724
	 * @return boolean
725
	 */
726 6
	protected function isWorkingCopyPathStatus(array $status, $path_status)
727
	{
728 6
		$tree_conflicted = $status['tree-conflicted'];
729
730 6
		if ( $path_status === self::STATUS_NORMAL ) {
731
			// Normal if all of 3 are normal.
732 6
			return $status['item'] === $path_status && $status['props'] === $path_status && !$tree_conflicted;
733
		}
734 5
		elseif ( $path_status === self::STATUS_CONFLICTED ) {
735
			// Conflict if any of 3 has conflict.
736 2
			return $status['item'] === $path_status || $status['props'] === $path_status || $tree_conflicted;
737
		}
738 5
		elseif ( $path_status === self::STATUS_UNVERSIONED ) {
739 5
			return $status['item'] === $path_status && $status['props'] === self::STATUS_NONE;
740
		}
741
742 5
		return $status['item'] === $path_status;
743
	}
744
745
	/**
746
	 * Returns parent paths from given paths.
747
	 *
748
	 * @param array $paths Paths.
749
	 *
750
	 * @return array
751
	 */
752 1
	protected function getParentPaths(array $paths)
753
	{
754 1
		$ret = array();
755
756 1
		foreach ( $paths as $path ) {
757 1
			while ( $path !== '.' ) {
758 1
				$path = dirname($path);
759 1
				$ret[] = $path;
760
			}
761
		}
762
763 1
		return array_unique($ret);
764
	}
765
766
	/**
767
	 * Returns working copy changelists.
768
	 *
769
	 * @param string $wc_path Working copy path.
770
	 *
771
	 * @return array
772
	 */
773 2
	public function getWorkingCopyChangelists($wc_path)
774
	{
775 2
		$ret = array();
776 2
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
777
778 2
		foreach ( $status->changelist as $changelist ) {
779 1
			$ret[] = (string)$changelist['name'];
780
		}
781
782 2
		sort($ret, SORT_STRING);
783
784 2
		return $ret;
785
	}
786
787
	/**
788
	 * Returns revisions of paths in a working copy.
789
	 *
790
	 * @param string $wc_path Working copy path.
791
	 *
792
	 * @return array
793
	 */
794
	public function getWorkingCopyRevisions($wc_path)
795
	{
796
		$revisions = array();
797
		$status = $this->getCommand('status', '--xml --verbose {' . $wc_path . '}')->run();
798
799
		foreach ( $status->target as $target ) {
800
			if ( (string)$target['path'] !== $wc_path ) {
801
				continue;
802
			}
803
804
			foreach ( $target as $entry ) {
805
				$revision = (int)$entry->{'wc-status'}['revision'];
806
				$revisions[$revision] = true;
807
			}
808
		}
809
810
		// The "-1" revision happens, when external is deleted.
811
		// The "0" happens for not committed paths (e.g. added).
812
		unset($revisions[-1], $revisions[0]);
813
814
		return array_keys($revisions);
815
	}
816
817
	/**
818
	 * Determines if there is a working copy on a given path.
819
	 *
820
	 * @param string $path Path.
821
	 *
822
	 * @return boolean
823
	 * @throws \InvalidArgumentException When path isn't found.
824
	 * @throws RepositoryCommandException When repository command failed to execute.
825
	 */
826
	public function isWorkingCopy($path)
827
	{
828
		if ( $this->isUrl($path) || !file_exists($path) ) {
829
			throw new \InvalidArgumentException('Path "' . $path . '" not found.');
830
		}
831
832
		try {
833
			$wc_url = $this->getWorkingCopyUrl($path);
834
		}
835
		catch ( RepositoryCommandException $e ) {
836
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_NOT_WORKING_COPY ) {
837
				return false;
838
			}
839
840
			throw $e;
841
		}
842
843
		return $wc_url != '';
844
	}
845
846
	/**
847
	 * Returns list of just merged revisions.
848
	 *
849
	 * @param string $wc_path Working copy path, where merge happens.
850
	 *
851
	 * @return array
852
	 */
853 2
	public function getFreshMergedRevisions($wc_path)
854
	{
855 2
		$final_paths = array();
856 2
		$old_paths = $this->getMergedRevisions($wc_path, 'BASE');
857 2
		$new_paths = $this->getMergedRevisions($wc_path);
858
859 2
		if ( $old_paths === $new_paths ) {
860 1
			return array();
861
		}
862
863 1
		foreach ( $new_paths as $new_path => $new_merged_revisions ) {
864 1
			if ( !isset($old_paths[$new_path]) ) {
865
				// Merge from new path.
866 1
				$final_paths[$new_path] = $this->_revisionListParser->expandRanges(
867 1
					explode(',', $new_merged_revisions)
868
				);
869
			}
870 1
			elseif ( $new_merged_revisions != $old_paths[$new_path] ) {
871
				// Merge on existing path.
872 1
				$new_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
873 1
					explode(',', $new_merged_revisions)
874
				);
875 1
				$old_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
876 1
					explode(',', $old_paths[$new_path])
877
				);
878 1
				$final_paths[$new_path] = array_values(
879 1
					array_diff($new_merged_revisions_parsed, $old_merged_revisions_parsed)
880
				);
881
			}
882
		}
883
884 1
		return $final_paths;
885
	}
886
887
	/**
888
	 * Returns list of merged revisions per path.
889
	 *
890
	 * @param string  $wc_path  Merge target: working copy path.
891
	 * @param integer $revision Revision.
892
	 *
893
	 * @return array
894
	 */
895 2
	protected function getMergedRevisions($wc_path, $revision = null)
896
	{
897 2
		$paths = array();
898
899 2
		$merge_info = $this->getProperty('svn:mergeinfo', $wc_path, $revision);
900 2
		$merge_info = array_filter(explode("\n", $merge_info));
901
902 2
		foreach ( $merge_info as $merge_info_line ) {
903 2
			list($path, $revisions) = explode(':', $merge_info_line, 2);
904 2
			$paths[$path] = $revisions;
905
		}
906
907 2
		return $paths;
908
	}
909
910
	/**
911
	 * Returns file contents at given revision.
912
	 *
913
	 * @param string  $path_or_url Path or url.
914
	 * @param integer $revision    Revision.
915
	 *
916
	 * @return string
917
	 */
918 1
	public function getFileContent($path_or_url, $revision)
919
	{
920
		return $this
921 1
			->withCache(self::SVN_CAT_CACHE_DURATION)
922 1
			->getCommand('cat', '{' . $path_or_url . '} --revision ' . $revision)
923 1
			->run();
924
	}
925
926
}
927