Completed
Push — master ( c8b861...77fe96 )
by Alexander
02:45
created

Connector::_getSvnInfoEntry()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

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