Completed
Push — master ( a04d7d...af82d7 )
by Alexander
03:22
created

Connector::withCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

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