Completed
Push — master ( 8c59ef...24dea2 )
by Alexander
03:02
created

Connector::getCommand()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 11
cts 11
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 11
nc 2
nop 2
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 96
	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 96
		$this->_configEditor = $config_editor;
120 96
		$this->_processFactory = $process_factory;
121 96
		$this->_io = $io;
122 96
		$this->_cacheManager = $cache_manager;
123 96
		$this->_revisionListParser = $revision_list_parser;
124
125 96
		$cache_duration = $this->_configEditor->get('repository-connector.last-revision-cache-duration');
126
127 96
		if ( (string)$cache_duration === '' || substr($cache_duration, 0, 1) === '0' ) {
128 4
			$cache_duration = 0;
129
		}
130
131 96
		$this->_lastRevisionCacheDuration = $cache_duration;
132
133 96
		$this->prepareSvnCommand();
134 96
	}
135
136
	/**
137
	 * Prepares static part of svn command to be used across the script.
138
	 *
139
	 * @return void
140
	 */
141 96
	protected function prepareSvnCommand()
142
	{
143 96
		$username = $this->_configEditor->get('repository-connector.username');
144 96
		$password = $this->_configEditor->get('repository-connector.password');
145
146 96
		$this->_svnCommand .= ' --non-interactive';
147
148 96
		if ( $username ) {
149 17
			$this->_svnCommand .= ' --username ' . $username;
150
		}
151
152 96
		if ( $password ) {
153 17
			$this->_svnCommand .= ' --password ' . $password;
154
		}
155 96
	}
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 52
	public function getCommand($sub_command, $param_string = null)
166
	{
167 52
		$command_line = $this->buildCommand($sub_command, $param_string);
168
169 51
		$command = new Command(
170 51
			$command_line,
171 51
			$this->_io,
172 51
			$this->_cacheManager,
173 51
			$this->_processFactory
174
		);
175
176 51
		if ( isset($this->_nextCommandCacheDuration) ) {
177 25
			$command->setCacheDuration($this->_nextCommandCacheDuration);
178 25
			$this->_nextCommandCacheDuration = null;
179
		}
180
181 51
		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 52
	protected function buildCommand($sub_command, $param_string = null)
194
	{
195 52
		if ( strpos($sub_command, ' ') !== false ) {
196 1
			throw new \InvalidArgumentException('The "' . $sub_command . '" sub-command contains spaces.');
197
		}
198
199 51
		$command_line = $this->_svnCommand;
200
201 51
		if ( !empty($sub_command) ) {
202 46
			$command_line .= ' ' . $sub_command;
203
		}
204
205 51
		if ( !empty($param_string) ) {
206 49
			$command_line .= ' ' . $param_string;
207
		}
208
209 51
		$command_line = preg_replace_callback(
210 51
			'/\{([^\}]*)\}/',
211 51
			function (array $matches) {
212 43
				return escapeshellarg($matches[1]);
213 51
			},
214 51
			$command_line
215
		);
216
217 51
		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 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 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']) . $this->getShortPropertiesStatus($status['props']);
559
			$line .= '   ' . $path;
560
561
			$ret[] = $line;
562
		}
563
564
		return implode(PHP_EOL, $ret);
565
	}
566
567
	/**
568
	 * Returns short item status.
569
	 *
570
	 * @param string $status Status.
571
	 *
572
	 * @return string
573
	 * @throws \InvalidArgumentException When unknown status given.
574
	 */
575
	protected function getShortItemStatus($status)
576
	{
577
		$status_map = array(
578
			'added' => 'A',
579
			'conflicted' => 'C',
580
			'deleted' => 'D',
581
			'external' => 'X',
582
			'ignored' => 'I',
583
			// '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...
584
			// '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...
585
			'missing' => '!',
586
			'modified' => 'M',
587
			'none' => ' ',
588
			'normal' => '_',
589
			// '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...
590
			'replaced' => 'R',
591
			'unversioned' => '?',
592
		);
593
594
		if ( !isset($status_map[$status]) ) {
595
			throw new \InvalidArgumentException('The "' . $status . '" item status is unknown.');
596
		}
597
598
		return $status_map[$status];
599
	}
600
601
	/**
602
	 * Returns short item status.
603
	 *
604
	 * @param string $status Status.
605
	 *
606
	 * @return string
607
	 * @throws \InvalidArgumentException When unknown status given.
608
	 */
609
	protected function getShortPropertiesStatus($status)
610
	{
611
		$status_map = array(
612
			'conflicted' => 'C',
613
			'modified' => 'M',
614
			'normal' => '_',
615
			'none' => ' ',
616
		);
617
618
		if ( !isset($status_map[$status]) ) {
619
			throw new \InvalidArgumentException('The "' . $status . '" properties status is unknown.');
620
		}
621
622
		return $status_map[$status];
623
	}
624
625
	/**
626
	 * Returns working copy status.
627
	 *
628
	 * @param string      $wc_path         Working copy path.
629
	 * @param string|null $changelist      Changelist.
630
	 * @param array       $except_statuses Except statuses.
631
	 *
632
	 * @return array
633
	 * @throws \InvalidArgumentException When changelist doens't exist.
634
	 */
635 8
	public function getWorkingCopyStatus(
636
		$wc_path,
637
		$changelist = null,
638
		array $except_statuses = array(self::STATUS_UNVERSIONED, self::STATUS_EXTERNAL)
639
	) {
640 8
		$all_paths = array();
641
642 8
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
643
644 8
		if ( !strlen($changelist) ) {
645
			// Accept all entries from "target" and "changelist" nodes.
646 6
			foreach ( $status->children() as $entries ) {
647 6
				$child_name = $entries->getName();
648
649 6
				if ( $child_name === 'target' || $child_name === 'changelist' ) {
650 6
					$all_paths += $this->processStatusEntryNodes($wc_path, $entries);
651
				}
652
			}
653
		}
654
		else {
655
			// Accept all entries from "changelist" node and parent folders from "target" node.
656 2
			foreach ( $status->changelist as $changelist_entries ) {
657 2
				if ( (string)$changelist_entries['name'] === $changelist ) {
658 2
					$all_paths += $this->processStatusEntryNodes($wc_path, $changelist_entries);
659
				}
660
			}
661
662 2
			if ( !$all_paths ) {
663 1
				throw new \InvalidArgumentException('The "' . $changelist . '" changelist doens\'t exist.');
664
			}
665
666 1
			$parent_paths = $this->getParentPaths(array_keys($all_paths));
667
668 1
			foreach ( $status->target as $target_entries ) {
669 1
				foreach ( $this->processStatusEntryNodes($wc_path, $target_entries) as $path => $path_data ) {
670 1
					if ( in_array($path, $parent_paths) ) {
671 1
						$all_paths[$path] = $path_data;
672
					}
673
				}
674
			}
675
676 1
			ksort($all_paths, SORT_STRING);
677
		}
678
679 7
		$changed_paths = array();
680
681 7
		foreach ( $all_paths as $path => $status ) {
682
			// Exclude paths, that haven't changed (e.g. from changelists).
683 6
			if ( $this->isWorkingCopyPathStatus($status, self::STATUS_NORMAL) ) {
684 4
				continue;
685
			}
686
687
			// Exclude paths with requested statuses.
688 6
			if ( $except_statuses ) {
689 5
				foreach ( $except_statuses as $except_status ) {
690 5
					if ( $this->isWorkingCopyPathStatus($status, $except_status) ) {
691 5
						continue 2;
692
					}
693
				}
694
			}
695
696 6
			$changed_paths[$path] = $status;
697
		}
698
699 7
		return $changed_paths;
700
	}
701
702
	/**
703
	 * Processes "entry" nodes from "svn status" command.
704
	 *
705
	 * @param string            $wc_path Working copy path.
706
	 * @param \SimpleXMLElement $entries Entries.
707
	 *
708
	 * @return array
709
	 */
710 6
	protected function processStatusEntryNodes($wc_path, \SimpleXMLElement $entries)
711
	{
712 6
		$ret = array();
713
714 6
		foreach ( $entries as $entry ) {
715 6
			$path = (string)$entry['path'];
716 6
			$path = $path === $wc_path ? '.' : str_replace($wc_path . '/', '', $path);
717
718 6
			$ret[$path] = array(
719 6
				'item' => (string)$entry->{'wc-status'}['item'],
720 6
				'props' => (string)$entry->{'wc-status'}['props'],
721 6
				'tree-conflicted' => (string)$entry->{'wc-status'}['tree-conflicted'] === 'true',
722
			);
723
		}
724
725 6
		return $ret;
726
	}
727
728
	/**
729
	 * Detects specific path status.
730
	 *
731
	 * @param array  $status      Path status.
732
	 * @param string $path_status Expected path status.
733
	 *
734
	 * @return boolean
735
	 */
736 6
	protected function isWorkingCopyPathStatus(array $status, $path_status)
737
	{
738 6
		$tree_conflicted = $status['tree-conflicted'];
739
740 6
		if ( $path_status === self::STATUS_NORMAL ) {
741
			// Normal if all of 3 are normal.
742 6
			return $status['item'] === $path_status && $status['props'] === $path_status && !$tree_conflicted;
743
		}
744 5
		elseif ( $path_status === self::STATUS_CONFLICTED ) {
745
			// Conflict if any of 3 has conflict.
746 2
			return $status['item'] === $path_status || $status['props'] === $path_status || $tree_conflicted;
747
		}
748 5
		elseif ( $path_status === self::STATUS_UNVERSIONED ) {
749 5
			return $status['item'] === $path_status && $status['props'] === self::STATUS_NONE;
750
		}
751
752 5
		return $status['item'] === $path_status;
753
	}
754
755
	/**
756
	 * Returns parent paths from given paths.
757
	 *
758
	 * @param array $paths Paths.
759
	 *
760
	 * @return array
761
	 */
762 1
	protected function getParentPaths(array $paths)
763
	{
764 1
		$ret = array();
765
766 1
		foreach ( $paths as $path ) {
767 1
			while ( $path !== '.' ) {
768 1
				$path = dirname($path);
769 1
				$ret[] = $path;
770
			}
771
		}
772
773 1
		return array_unique($ret);
774
	}
775
776
	/**
777
	 * Returns working copy changelists.
778
	 *
779
	 * @param string $wc_path Working copy path.
780
	 *
781
	 * @return array
782
	 */
783 2
	public function getWorkingCopyChangelists($wc_path)
784
	{
785 2
		$ret = array();
786 2
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
787
788 2
		foreach ( $status->changelist as $changelist ) {
789 1
			$ret[] = (string)$changelist['name'];
790
		}
791
792 2
		sort($ret, SORT_STRING);
793
794 2
		return $ret;
795
	}
796
797
	/**
798
	 * Returns revisions of paths in a working copy.
799
	 *
800
	 * @param string $wc_path Working copy path.
801
	 *
802
	 * @return array
803
	 */
804
	public function getWorkingCopyRevisions($wc_path)
805
	{
806
		$revisions = array();
807
		$status = $this->getCommand('status', '--xml --verbose {' . $wc_path . '}')->run();
808
809
		foreach ( $status->target as $target ) {
810
			if ( (string)$target['path'] !== $wc_path ) {
811
				continue;
812
			}
813
814
			foreach ( $target as $entry ) {
815
				$revision = (int)$entry->{'wc-status'}['revision'];
816
				$revisions[$revision] = true;
817
			}
818
		}
819
820
		// The "-1" revision happens, when external is deleted.
821
		// The "0" happens for not committed paths (e.g. added).
822
		unset($revisions[-1], $revisions[0]);
823
824
		return array_keys($revisions);
825
	}
826
827
	/**
828
	 * Determines if there is a working copy on a given path.
829
	 *
830
	 * @param string $path Path.
831
	 *
832
	 * @return boolean
833
	 * @throws \InvalidArgumentException When path isn't found.
834
	 * @throws RepositoryCommandException When repository command failed to execute.
835
	 */
836
	public function isWorkingCopy($path)
837
	{
838
		if ( $this->isUrl($path) || !file_exists($path) ) {
839
			throw new \InvalidArgumentException('Path "' . $path . '" not found.');
840
		}
841
842
		try {
843
			$wc_url = $this->getWorkingCopyUrl($path);
844
		}
845
		catch ( RepositoryCommandException $e ) {
846
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_NOT_WORKING_COPY ) {
847
				return false;
848
			}
849
850
			throw $e;
851
		}
852
853
		return $wc_url != '';
854
	}
855
856
	/**
857
	 * Returns list of just merged revisions.
858
	 *
859
	 * @param string $wc_path Working copy path, where merge happens.
860
	 *
861
	 * @return array
862
	 */
863 2
	public function getFreshMergedRevisions($wc_path)
864
	{
865 2
		$final_paths = array();
866 2
		$old_paths = $this->getMergedRevisions($wc_path, 'BASE');
867 2
		$new_paths = $this->getMergedRevisions($wc_path);
868
869 2
		if ( $old_paths === $new_paths ) {
870 1
			return array();
871
		}
872
873 1
		foreach ( $new_paths as $new_path => $new_merged_revisions ) {
874 1
			if ( !isset($old_paths[$new_path]) ) {
875
				// Merge from new path.
876 1
				$final_paths[$new_path] = $this->_revisionListParser->expandRanges(
877 1
					explode(',', $new_merged_revisions)
878
				);
879
			}
880 1
			elseif ( $new_merged_revisions != $old_paths[$new_path] ) {
881
				// Merge on existing path.
882 1
				$new_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
883 1
					explode(',', $new_merged_revisions)
884
				);
885 1
				$old_merged_revisions_parsed = $this->_revisionListParser->expandRanges(
886 1
					explode(',', $old_paths[$new_path])
887
				);
888 1
				$final_paths[$new_path] = array_values(
889 1
					array_diff($new_merged_revisions_parsed, $old_merged_revisions_parsed)
890
				);
891
			}
892
		}
893
894 1
		return $final_paths;
895
	}
896
897
	/**
898
	 * Returns list of merged revisions per path.
899
	 *
900
	 * @param string  $wc_path  Merge target: working copy path.
901
	 * @param integer $revision Revision.
902
	 *
903
	 * @return array
904
	 */
905 2
	protected function getMergedRevisions($wc_path, $revision = null)
906
	{
907 2
		$paths = array();
908
909 2
		$merge_info = $this->getProperty('svn:mergeinfo', $wc_path, $revision);
910 2
		$merge_info = array_filter(explode("\n", $merge_info));
911
912 2
		foreach ( $merge_info as $merge_info_line ) {
913 2
			list($path, $revisions) = explode(':', $merge_info_line, 2);
914 2
			$paths[$path] = $revisions;
915
		}
916
917 2
		return $paths;
918
	}
919
920
	/**
921
	 * Returns file contents at given revision.
922
	 *
923
	 * @param string  $path_or_url Path or url.
924
	 * @param integer $revision    Revision.
925
	 *
926
	 * @return string
927
	 */
928 1
	public function getFileContent($path_or_url, $revision)
929
	{
930
		return $this
931 1
			->withCache(self::SVN_CAT_CACHE_DURATION)
932 1
			->getCommand('cat', '{' . $path_or_url . '} --revision ' . $revision)
933 1
			->run();
934
	}
935
936
}
937