Completed
Push — master ( e44400...cbfd05 )
by Alexander
03:19
created

Connector::prepareSvnCommand()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 15
ccs 11
cts 11
cp 1
rs 9.4285
cc 3
eloc 8
nc 4
nop 0
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
20
/**
21
 * Executes command on the repository.
22
 */
23
class Connector
24
{
25
26
	const STATUS_UNVERSIONED = 'unversioned';
27
28
	const URL_REGEXP = '#([\w]*)://([^/@\s\']+@)?([^/@:\s\']+)(:\d+)?([^@\s\']*)?#';
29
30
	const SVN_INFO_CACHE_DURATION = '1 year';
31
32
	/**
33
	 * Reference to configuration.
34
	 *
35
	 * @var ConfigEditor
36
	 */
37
	private $_configEditor;
38
39
	/**
40
	 * Process factory.
41
	 *
42
	 * @var IProcessFactory
43
	 */
44
	private $_processFactory;
45
46
	/**
47
	 * Console IO.
48
	 *
49
	 * @var ConsoleIO
50
	 */
51
	private $_io;
52
53
	/**
54
	 * Cache manager.
55
	 *
56
	 * @var CacheManager
57
	 */
58
	private $_cacheManager;
59
60
	/**
61
	 * Path to an svn command.
62
	 *
63
	 * @var string
64
	 */
65
	private $_svnCommand = 'svn';
66
67
	/**
68
	 * Cache duration for next invoked command.
69
	 *
70
	 * @var mixed
71
	 */
72
	private $_nextCommandCacheDuration = null;
73
74
	/**
75
	 * Whatever to cache last repository revision or not.
76
	 *
77
	 * @var mixed
78
	 */
79
	private $_lastRevisionCacheDuration = null;
80
81
	/**
82
	 * Creates repository connector.
83
	 *
84
	 * @param ConfigEditor    $config_editor   ConfigEditor.
85
	 * @param IProcessFactory $process_factory Process factory.
86
	 * @param ConsoleIO       $io              Console IO.
87
	 * @param CacheManager    $cache_manager   Cache manager.
88
	 */
89 71
	public function __construct(
90
		ConfigEditor $config_editor,
91
		IProcessFactory $process_factory,
92
		ConsoleIO $io,
93
		CacheManager $cache_manager
94
	) {
95 71
		$this->_configEditor = $config_editor;
96 71
		$this->_processFactory = $process_factory;
97 71
		$this->_io = $io;
98 71
		$this->_cacheManager = $cache_manager;
99
100 71
		$cache_duration = $this->_configEditor->get('repository-connector.last-revision-cache-duration');
101
102 71
		if ( (string)$cache_duration === '' || substr($cache_duration, 0, 1) === '0' ) {
103 4
			$cache_duration = 0;
104 4
		}
105
106 71
		$this->_lastRevisionCacheDuration = $cache_duration;
107
108 71
		$this->prepareSvnCommand();
109 71
	}
110
111
	/**
112
	 * Prepares static part of svn command to be used across the script.
113
	 *
114
	 * @return void
115
	 */
116 71
	protected function prepareSvnCommand()
117
	{
118 71
		$username = $this->_configEditor->get('repository-connector.username');
119 71
		$password = $this->_configEditor->get('repository-connector.password');
120
121 71
		$this->_svnCommand .= ' --non-interactive';
122
123 71
		if ( $username ) {
124 15
			$this->_svnCommand .= ' --username ' . $username;
125 15
		}
126
127 71
		if ( $password ) {
128 15
			$this->_svnCommand .= ' --password ' . $password;
129 15
		}
130 71
	}
131
132
	/**
133
	 * Builds a command.
134
	 *
135
	 * @param string      $sub_command  Sub command.
136
	 * @param string|null $param_string Parameter string.
137
	 *
138
	 * @return Command
139
	 */
140 32
	public function getCommand($sub_command, $param_string = null)
141
	{
142 32
		$command_line = $this->buildCommand($sub_command, $param_string);
143
144 31
		$command = new Command(
145 31
			$this->_processFactory->createProcess($command_line, 1200),
146 31
			$this->_io,
147 31
			$this->_cacheManager
148 31
		);
149
150 31
		if ( isset($this->_nextCommandCacheDuration) ) {
151 19
			$command->setCacheDuration($this->_nextCommandCacheDuration);
152 19
			$this->_nextCommandCacheDuration = null;
153 19
		}
154
155 31
		return $command;
156
	}
157
158
	/**
159
	 * Builds command from given arguments.
160
	 *
161
	 * @param string $sub_command  Command.
162
	 * @param string $param_string Parameter string.
163
	 *
164
	 * @return string
165
	 * @throws \InvalidArgumentException When command contains spaces.
166
	 */
167 32
	protected function buildCommand($sub_command, $param_string = null)
168
	{
169 32
		if ( strpos($sub_command, ' ') !== false ) {
170 1
			throw new \InvalidArgumentException('The "' . $sub_command . '" sub-command contains spaces.');
171
		}
172
173 31
		$command_line = $this->_svnCommand;
174
175 31
		if ( !empty($sub_command) ) {
176 26
			$command_line .= ' ' . $sub_command;
177 26
		}
178
179 31
		if ( !empty($param_string) ) {
180 29
			$command_line .= ' ' . $param_string;
181 29
		}
182
183 31
		$command_line = preg_replace_callback(
184 31
			'/\{([^\}]*)\}/',
185 31
			function (array $matches) {
186 23
				return escapeshellarg($matches[1]);
187 31
			},
188
			$command_line
189 31
		);
190
191 31
		return $command_line;
192
	}
193
194
	/**
195
	 * Sets cache configuration for next created command.
196
	 *
197
	 * @param mixed $cache_duration Cache duration.
198
	 *
199
	 * @return self
200
	 */
201 20
	public function withCache($cache_duration)
202
	{
203 20
		$this->_nextCommandCacheDuration = $cache_duration;
204
205 20
		return $this;
206
	}
207
208
	/**
209
	 * Returns property value.
210
	 *
211
	 * @param string $name        Property name.
212
	 * @param string $path_or_url Path to get property from.
213
	 * @param mixed  $revision    Revision.
214
	 *
215
	 * @return string
216
	 */
217 2
	public function getProperty($name, $path_or_url, $revision = null)
218
	{
219 2
		$param_string = $name . ' {' . $path_or_url . '}';
220
221 2
		if ( isset($revision) ) {
222 1
			$param_string .= ' --revision ' . $revision;
223 1
		}
224
225 2
		return $this->getCommand('propget', $param_string)->run();
226
	}
227
228
	/**
229
	 * Returns relative path of given path/url to the root of the repository.
230
	 *
231
	 * @param string $path_or_url Path or url.
232
	 *
233
	 * @return string
234
	 */
235 3
	public function getRelativePath($path_or_url)
236
	{
237 3
		$svn_info_entry = $this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION);
238
239 3
		return preg_replace(
240 3
			'/^' . preg_quote($svn_info_entry->repository->root, '/') . '/',
241 3
			'',
242 3
			(string)$svn_info_entry->url,
243
			1
244 3
		);
245
	}
246
247
	/**
248
	 * Returns repository root url from given path/url.
249
	 *
250
	 * @param string $path_or_url Path or url.
251
	 *
252
	 * @return string
253
	 */
254 3
	public function getRootUrl($path_or_url)
255
	{
256 3
		return (string)$this->_getSvnInfoEntry($path_or_url, self::SVN_INFO_CACHE_DURATION)->repository->root;
257
	}
258
259
	/**
260
	 * Determines if path is a root of the ref.
261
	 *
262
	 * @param string $path Path to a file.
263
	 *
264
	 * @return boolean
265
	 */
266 13
	public function isRefRoot($path)
267
	{
268 13
		$ref = $this->getRefByPath($path);
269
270 13
		if ( $ref === false ) {
271 4
			return false;
272
		}
273
274 9
		return preg_match('#/' . preg_quote($ref, '#') . '/$#', $path) > 0;
275
	}
276
277
	/**
278
	 * Detects ref from given path.
279
	 *
280
	 * @param string $path Path to a file.
281
	 *
282
	 * @return string|boolean
283
	 * @see    getProjectUrl
284
	 */
285 22
	public function getRefByPath($path)
286
	{
287 22
		if ( preg_match('#^.*?/(trunk|branches/[^/]+|tags/[^/]+|releases/[^/]+).*$#', $path, $regs) ) {
288 14
			return $regs[1];
289
		}
290
291 8
		return false;
292
	}
293
294
	/**
295
	 * Returns URL of the working copy.
296
	 *
297
	 * @param string $wc_path Working copy path.
298
	 *
299
	 * @return string
300
	 * @throws RepositoryCommandException When repository command failed to execute.
301
	 */
302 8
	public function getWorkingCopyUrl($wc_path)
303
	{
304 8
		if ( $this->isUrl($wc_path) ) {
305 2
			return $this->removeCredentials($wc_path);
306
		}
307
308
		try {
309 6
			$wc_url = (string)$this->_getSvnInfoEntry($wc_path, self::SVN_INFO_CACHE_DURATION)->url;
310
		}
311 6
		catch ( RepositoryCommandException $e ) {
312 3
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_UPGRADE_REQUIRED ) {
313 2
				$message = explode(PHP_EOL, $e->getMessage());
314
315 2
				$this->_io->writeln(array('', '<error>' . end($message) . '</error>', ''));
316
317 2
				if ( $this->_io->askConfirmation('Run "svn upgrade"', false) ) {
318 1
					$this->getCommand('upgrade', '{' . $wc_path . '}')->runLive();
319
320 1
					return $this->getWorkingCopyUrl($wc_path);
321
				}
322 1
			}
323
324 2
			throw $e;
325
		}
326
327 3
		return $wc_url;
328
	}
329
330
	/**
331
	 * Returns last changed revision on path/url.
332
	 *
333
	 * @param string $path_or_url Path or url.
334
	 *
335
	 * @return integer
336
	 */
337 7
	public function getLastRevision($path_or_url)
338
	{
339
		// Cache "svn info" commands to remote urls, not the working copy.
340 7
		$cache_duration = $this->isUrl($path_or_url) ? $this->_lastRevisionCacheDuration : null;
341
342 7
		return (int)$this->_getSvnInfoEntry($path_or_url, $cache_duration)->commit['revision'];
343
	}
344
345
	/**
346
	 * Determines if given path is in fact an url.
347
	 *
348
	 * @param string $path Path.
349
	 *
350
	 * @return boolean
351
	 */
352 24
	public function isUrl($path)
353
	{
354 24
		return strpos($path, '://') !== false;
355
	}
356
357
	/**
358
	 * Removes credentials from url.
359
	 *
360
	 * @param string $url URL.
361
	 *
362
	 * @return string
363
	 * @throws \InvalidArgumentException When non-url given.
364
	 */
365 15
	public function removeCredentials($url)
366
	{
367 15
		if ( !$this->isUrl($url) ) {
368 1
			throw new \InvalidArgumentException('Unable to remove credentials from "' . $url . '" path.');
369
		}
370
371 14
		return preg_replace('#^(.*)://(.*)@(.*)$#', '$1://$3', $url);
372
	}
373
374
	/**
375
	 * Returns project url (container for "trunk/branches/tags/releases" folders).
376
	 *
377
	 * @param string $repository_url Repository url.
378
	 *
379
	 * @return string
380
	 * @see    getRefByPath
381
	 */
382 9
	public function getProjectUrl($repository_url)
383
	{
384 9
		if ( preg_match('#^(.*?)/(trunk|branches|tags|releases).*$#', $repository_url, $regs) ) {
385 8
			return $regs[1];
386
		}
387
388
		// When known folder structure not detected consider, that project url was already given.
389 1
		return $repository_url;
390
	}
391
392
	/**
393
	 * Returns "svn info" entry for path or url.
394
	 *
395
	 * @param string $path_or_url    Path or url.
396
	 * @param mixed  $cache_duration Cache duration.
397
	 *
398
	 * @return \SimpleXMLElement
399
	 * @throws \LogicException When unexpected 'svn info' results retrieved.
400
	 */
401 19
	private function _getSvnInfoEntry($path_or_url, $cache_duration = null)
402
	{
403
		// Cache "svn info" commands to remote urls, not the working copy.
404 19
		if ( !isset($cache_duration) && $this->isUrl($path_or_url) ) {
405
			$cache_duration = self::SVN_INFO_CACHE_DURATION;
406
		}
407
408
		// Remove credentials from url, because "svn info" fails, when used on repository root.
409 19
		if ( $this->isUrl($path_or_url) ) {
410 10
			$path_or_url = $this->removeCredentials($path_or_url);
411 10
		}
412
413
		// TODO: When wc path (not url) is given, then credentials can be present in "svn info" result anyway.
414 19
		$svn_info = $this->withCache($cache_duration)->getCommand('info', '--xml {' . $path_or_url . '}')->run();
415
416
		// When getting remote "svn info", then path is last folder only.
417 17
		$svn_info_path = $this->_getSvnInfoEntryPath($svn_info->entry);
418
419
		// In SVN 1.7+, when doing "svn info" on repository root url.
420 17
		if ( $svn_info_path === '.' ) {
421
			$svn_info_path = $path_or_url;
422
		}
423
424 17
		if ( basename($svn_info_path) != basename($path_or_url) ) {
425 1
			throw new \LogicException('The directory "' . $path_or_url . '" not found in "svn info" command results.');
426
		}
427
428 16
		return $svn_info->entry;
429
	}
430
431
	/**
432
	 * Returns path of "svn info" entry.
433
	 *
434
	 * @param \SimpleXMLElement $svn_info_entry The "entry" node of "svn info" command.
435
	 *
436
	 * @return string
437
	 */
438 17
	private function _getSvnInfoEntryPath(\SimpleXMLElement $svn_info_entry)
439
	{
440
		// SVN 1.7+.
441 17
		$path = (string)$svn_info_entry->{'wc-info'}->{'wcroot-abspath'};
442
443 17
		if ( $path ) {
444 1
			return $path;
445
		}
446
447
		// SVN 1.6-.
448 16
		return (string)$svn_info_entry['path'];
449
	}
450
451
	/**
452
	 * Returns revision, when path was added to repository.
453
	 *
454
	 * @param string $url Url.
455
	 *
456
	 * @return integer
457
	 * @throws \InvalidArgumentException When not an url was given.
458
	 */
459
	public function getFirstRevision($url)
460
	{
461
		if ( !$this->isUrl($url) ) {
462
			throw new \InvalidArgumentException('The repository URL "' . $url . '" is invalid.');
463
		}
464
465
		$log = $this->withCache('1 year')->getCommand('log', ' -r 1:HEAD --limit 1 --xml {' . $url . '}')->run();
466
467
		return (int)$log->logentry['revision'];
468
	}
469
470
	/**
471
	 * Returns conflicts in working copy.
472
	 *
473
	 * @param string $wc_path Working copy path.
474
	 *
475
	 * @return array
476
	 */
477
	public function getWorkingCopyConflicts($wc_path)
478
	{
479
		$ret = array();
480
481
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
482
			if ( $status['item'] == 'conflicted' || $status['props'] == 'conflicted' || $status['tree-conflicted'] ) {
483
				$ret[] = $path;
484
			}
485
		}
486
487
		return $ret;
488
	}
489
490
	/**
491
	 * Returns compact working copy status.
492
	 *
493
	 * @param string  $wc_path          Working copy path.
494
	 * @param boolean $with_unversioned With unversioned.
495
	 *
496
	 * @return string
497
	 */
498
	public function getCompactWorkingCopyStatus($wc_path, $with_unversioned = true)
499
	{
500
		$ret = array();
501
502
		foreach ( $this->getWorkingCopyStatus($wc_path) as $path => $status ) {
503
			if ( !$with_unversioned && $status['item'] == self::STATUS_UNVERSIONED ) {
504
				continue;
505
			}
506
507
			$line = $this->getShortItemStatus($status['item']) . $this->getShortPropertiesStatus($status['props']);
508
			$line .= '   ' . $path;
509
510
			$ret[] = $line;
511
		}
512
513
		return implode(PHP_EOL, $ret);
514
	}
515
516
	/**
517
	 * Returns short item status.
518
	 *
519
	 * @param string $status Status.
520
	 *
521
	 * @return string
522
	 * @throws \InvalidArgumentException When unknown status given.
523
	 */
524
	protected function getShortItemStatus($status)
525
	{
526
		$status_map = array(
527
			'added' => 'A',
528
			'conflicted' => 'C',
529
			'deleted' => 'D',
530
			'external' => 'X',
531
			'ignored' => 'I',
532
			// 'incomplete' => '',
533
			// 'merged' => '',
534
			'missing' => '!',
535
			'modified' => 'M',
536
			'none' => ' ',
537
			'normal' => '_',
538
			// 'obstructed' => '',
539
			'replaced' => 'R',
540
			'unversioned' => '?',
541
		);
542
543
		if ( !isset($status_map[$status]) ) {
544
			throw new \InvalidArgumentException('The "' . $status . '" item status is unknown.');
545
		}
546
547
		return $status_map[$status];
548
	}
549
550
	/**
551
	 * Returns short item status.
552
	 *
553
	 * @param string $status Status.
554
	 *
555
	 * @return string
556
	 * @throws \InvalidArgumentException When unknown status given.
557
	 */
558
	protected function getShortPropertiesStatus($status)
559
	{
560
		$status_map = array(
561
			'conflicted' => 'C',
562
			'modified' => 'M',
563
			'normal' => '_',
564
			'none' => ' ',
565
		);
566
567
		if ( !isset($status_map[$status]) ) {
568
			throw new \InvalidArgumentException('The "' . $status . '" properties status is unknown.');
569
		}
570
571
		return $status_map[$status];
572
	}
573
574
	/**
575
	 * Returns working copy status.
576
	 *
577
	 * @param string $wc_path Working copy path.
578
	 *
579
	 * @return array
580
	 */
581
	public function getWorkingCopyStatus($wc_path)
582
	{
583
		$ret = array();
584
		$status = $this->getCommand('status', '--xml {' . $wc_path . '}')->run();
585
586
		foreach ( $status->target as $target ) {
587
			if ( (string)$target['path'] !== $wc_path ) {
588
				continue;
589
			}
590
591
			foreach ( $target as $entry ) {
592
				$path = (string)$entry['path'];
593
594
				if ( $path === $wc_path ) {
595
					$path = '.';
596
				}
597
				else {
598
					$path = str_replace($wc_path . '/', '', $path);
599
				}
600
601
				$ret[$path] = array(
602
					'item' => (string)$entry->{'wc-status'}['item'],
603
					'props' => (string)$entry->{'wc-status'}['props'],
604
					'tree-conflicted' => (string)$entry->{'wc-status'}['tree-conflicted'] === 'true',
605
				);
606
			}
607
		}
608
609
		return $ret;
610
	}
611
612
	/**
613
	 * Determines if working copy contains mixed revisions.
614
	 *
615
	 * @param string $wc_path Working copy path.
616
	 *
617
	 * @return array
618
	 */
619
	public function isMixedRevisionWorkingCopy($wc_path)
620
	{
621
		$revisions = array();
622
		$status = $this->getCommand('status', '--xml --verbose {' . $wc_path . '}')->run();
623
624
		foreach ( $status->target as $target ) {
625
			if ( (string)$target['path'] !== $wc_path ) {
626
				continue;
627
			}
628
629
			foreach ( $target as $entry ) {
630
				$item_status = (string)$entry->{'wc-status'}['item'];
631
632
				if ( $item_status !== self::STATUS_UNVERSIONED ) {
633
					$revision = (int)$entry->{'wc-status'}['revision'];
634
					$revisions[$revision] = true;
635
				}
636
			}
637
		}
638
639
		return count($revisions) > 1;
640
	}
641
642
	/**
643
	 * Determines if there is a working copy on a given path.
644
	 *
645
	 * @param string $path Path.
646
	 *
647
	 * @return boolean
648
	 * @throws \InvalidArgumentException When path isn't found.
649
	 * @throws RepositoryCommandException When repository command failed to execute.
650
	 */
651
	public function isWorkingCopy($path)
652
	{
653
		if ( $this->isUrl($path) || !file_exists($path) ) {
654
			throw new \InvalidArgumentException('Path "' . $path . '" not found.');
655
		}
656
657
		try {
658
			$wc_url = $this->getWorkingCopyUrl($path);
659
		}
660
		catch ( RepositoryCommandException $e ) {
661
			if ( $e->getCode() == RepositoryCommandException::SVN_ERR_WC_NOT_WORKING_COPY ) {
662
				return false;
663
			}
664
665
			throw $e;
666
		}
667
668
		return $wc_url != '';
669
	}
670
671
}
672