Failed Conditions
Push — master ( 9ab24a...f8fe4f )
by Alexander
03:47
created

LogCommand::_crossReferencePathFromRepository()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 1
Bugs 1 Features 0
Metric Value
c 1
b 1
f 0
dl 0
loc 13
ccs 0
cts 8
cp 0
rs 9.4285
cc 3
eloc 7
nc 3
nop 1
crap 12
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\Command;
12
13
14
use ConsoleHelpers\SVNBuddy\Config\AbstractConfigSetting;
15
use ConsoleHelpers\SVNBuddy\Config\IntegerConfigSetting;
16
use ConsoleHelpers\SVNBuddy\Config\RegExpsConfigSetting;
17
use ConsoleHelpers\ConsoleKit\Exception\CommandException;
18
use ConsoleHelpers\SVNBuddy\Repository\Parser\RevisionListParser;
19
use ConsoleHelpers\SVNBuddy\Repository\RevisionLog\RevisionLog;
20
use ConsoleHelpers\SVNBuddy\Repository\RevisionLog\RevisionPrinter;
21
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
22
use Symfony\Component\Console\Input\InputArgument;
23
use Symfony\Component\Console\Input\InputInterface;
24
use Symfony\Component\Console\Input\InputOption;
25
use Symfony\Component\Console\Output\OutputInterface;
26
27
class LogCommand extends AbstractCommand implements IAggregatorAwareCommand, IConfigAwareCommand
28
{
29
30
	const SETTING_LOG_LIMIT = 'log.limit';
31
32
	const SETTING_LOG_MESSAGE_LIMIT = 'log.message-limit';
33
34
	const SETTING_LOG_MERGE_CONFLICT_REGEXPS = 'log.merge-conflict-regexps';
35
36
	/**
37
	 * Revision list parser.
38
	 *
39
	 * @var RevisionListParser
40
	 */
41
	private $_revisionListParser;
42
43
	/**
44
	 * Revision log
45
	 *
46
	 * @var RevisionLog
47
	 */
48
	private $_revisionLog;
49
50
	/**
51
	 * Revision printer.
52
	 *
53
	 * @var RevisionPrinter
54
	 */
55
	private $_revisionPrinter;
56
57
	/**
58
	 * Prepare dependencies.
59
	 *
60
	 * @return void
61
	 */
62
	protected function prepareDependencies()
63
	{
64
		parent::prepareDependencies();
65
66
		$container = $this->getContainer();
67
68
		$this->_revisionListParser = $container['revision_list_parser'];
69
		$this->_revisionPrinter = $container['revision_printer'];
70
	}
71
72
	/**
73
	 * {@inheritdoc}
74
	 */
75
	protected function configure()
76
	{
77
		$this->pathAcceptsUrl = true;
78
79
		$this
80
			->setName('log')
81
			->setDescription(
82
				'Show the log messages for a set of revisions, bugs, paths, refs, etc.'
83
			)
84
			->addArgument(
85
				'path',
86
				InputArgument::OPTIONAL,
87
				'Working copy path or URL',
88
				'.'
89
			)
90
			->addOption(
91
				'revisions',
92
				'r',
93
				InputOption::VALUE_REQUIRED,
94
				'List of revision(-s) and/or revision range(-s), e.g. <comment>53324</comment>, <comment>1224-4433</comment>'
95
			)
96
			->addOption(
97
				'bugs',
98
				'b',
99
				InputOption::VALUE_REQUIRED,
100
				'List of bug(-s), e.g. <comment>JRA-1234</comment>, <comment>43644</comment>'
101
			)
102
			->addOption(
103
				'refs',
104
				null,
105
				InputOption::VALUE_REQUIRED,
106
				'List of refs, e.g. <comment>trunk</comment>, <comment>branches/branch-name</comment>, <comment>tags/tag-name</comment>'
107
			)
108
			->addOption(
109
				'merges',
110
				null,
111
				InputOption::VALUE_NONE,
112
				'Show merge revisions only'
113
			)
114
			->addOption(
115
				'no-merges',
116
				null,
117
				InputOption::VALUE_NONE,
118
				'Hide merge revisions'
119
			)
120
			->addOption(
121
				'merged',
122
				null,
123
				InputOption::VALUE_NONE,
124
				'Shows only revisions, that were merged at least once'
125
			)
126
			->addOption(
127
				'not-merged',
128
				null,
129
				InputOption::VALUE_NONE,
130
				'Shows only revisions, that were not merged'
131
			)
132
			->addOption(
133
				'merged-by',
134
				null,
135
				InputOption::VALUE_REQUIRED,
136
				'Show revisions merged by list of revision(-s) and/or revision range(-s)'
137
			)
138
			->addOption(
139
				'action',
140
				null,
141
				InputOption::VALUE_REQUIRED,
142
				'Show revisions, whose paths were affected by specified action, e.g. <comment>A</comment>, <comment>M</comment>, <comment>R</comment>, <comment>D</comment>'
0 ignored issues
show
Coding Style introduced by
This line exceeds maximum limit of 140 characters; contains 172 characters

Overly long lines are hard to read on any screen. Most code styles therefor impose a maximum limit on the number of characters in a line.

Loading history...
143
			)
144
			->addOption(
145
				'kind',
146
				null,
147
				InputOption::VALUE_REQUIRED,
148
				'Show revisions, whose paths match specified kind, e.g. <comment>dir</comment> or <comment>file</comment>'
149
			)
150
			->addOption(
151
				'with-details',
152
				'd',
153
				InputOption::VALUE_NONE,
154
				'Shows detailed revision information, e.g. paths affected'
155
			)
156
			->addOption(
157
				'with-summary',
158
				's',
159
				InputOption::VALUE_NONE,
160
				'Shows number of added/changed/removed paths in the revision'
161
			)
162
			->addOption(
163
				'with-refs',
164
				null,
165
				InputOption::VALUE_NONE,
166
				'Shows revision refs'
167
			)
168
			->addOption(
169
				'with-merge-oracle',
170
				null,
171
				InputOption::VALUE_NONE,
172
				'Shows number of paths in the revision, that can cause conflict upon merging'
173
			)
174
			->addOption(
175
				'with-merge-status',
176
				null,
177
				InputOption::VALUE_NONE,
178
				'Shows merge revisions affecting this revision'
179
			)
180
			->addOption(
181
				'max-count',
182
				null,
183
				InputOption::VALUE_REQUIRED,
184
				'Limit the number of revisions to output'
185
			);
186
187
		parent::configure();
188
	}
189
190
	/**
191
	 * Return possible values for the named option
192
	 *
193
	 * @param string            $optionName Option name.
194
	 * @param CompletionContext $context    Completion context.
195
	 *
196
	 * @return array
197
	 */
198
	public function completeOptionValues($optionName, CompletionContext $context)
199
	{
200
		$ret = parent::completeOptionValues($optionName, $context);
201
202
		if ( $optionName === 'refs' ) {
203
			return $this->getAllRefs();
204
		}
205
		elseif ( $optionName === 'action' ) {
206
			return $this->getAllActions();
207
		}
208
		elseif ( $optionName === 'kind' ) {
209
			return $this->getAllKinds();
210
		}
211
212
		return $ret;
213
	}
214
215
	/**
216
	 * {@inheritdoc}
217
	 */
218
	public function initialize(InputInterface $input, OutputInterface $output)
219
	{
220
		parent::initialize($input, $output);
221
222
		$this->_revisionLog = $this->getRevisionLog($this->getWorkingCopyUrl());
223
	}
224
225
	/**
226
	 * {@inheritdoc}
227
	 *
228
	 * @throws \RuntimeException When both "--bugs" and "--revisions" options were specified.
229
	 * @throws CommandException When specified revisions are not present in current project.
230
	 * @throws CommandException When project contains no associated revisions.
231
	 */
232
	protected function execute(InputInterface $input, OutputInterface $output)
233
	{
234
		$profiling_start = microtime(true);
235
		$bugs = $this->getList($this->io->getOption('bugs'));
236
		$revisions = $this->getList($this->io->getOption('revisions'));
237
238
		if ( $bugs && $revisions ) {
239
			throw new \RuntimeException('The "--bugs" and "--revisions" options are mutually exclusive.');
240
		}
241
242
		$missing_revisions = array();
243
		$revisions_by_path = $this->getRevisionsByPath();
244
245
		if ( $revisions ) {
246
			$revisions = $this->_revisionListParser->expandRanges($revisions);
247
			$revisions_by_path = array_intersect($revisions_by_path, $revisions);
248
			$missing_revisions = array_diff($revisions, $revisions_by_path);
249
		}
250
		elseif ( $bugs ) {
251
			// Only show bug-related revisions on given path. The $missing_revisions is always empty.
252
			$revisions_from_bugs = $this->_revisionLog->find('bugs', $bugs);
253
			$revisions_by_path = array_intersect($revisions_by_path, $revisions_from_bugs);
254
		}
255
256
		$merged_by = $this->getList($this->io->getOption('merged-by'));
257
258
		if ( $merged_by ) {
259
			$merged_by = $this->_revisionListParser->expandRanges($merged_by);
260
			$revisions_by_path = $this->_revisionLog->find('merges', $merged_by);
261
		}
262
263
		if ( $this->io->getOption('merges') ) {
264
			$revisions_by_path = array_intersect($revisions_by_path, $this->_revisionLog->find('merges', 'all_merges'));
265
		}
266
		elseif ( $this->io->getOption('no-merges') ) {
267
			$revisions_by_path = array_diff($revisions_by_path, $this->_revisionLog->find('merges', 'all_merges'));
268
		}
269
270
		if ( $this->io->getOption('merged') ) {
271
			$revisions_by_path = array_intersect($revisions_by_path, $this->_revisionLog->find('merges', 'all_merged'));
272
		}
273
		elseif ( $this->io->getOption('not-merged') ) {
274
			$revisions_by_path = array_diff($revisions_by_path, $this->_revisionLog->find('merges', 'all_merged'));
275
		}
276
277
		$action = $this->io->getOption('action');
278
279
		if ( $action ) {
280
			if ( !in_array($action, $this->getAllActions()) ) {
281
				throw new CommandException('The "' . $action . '" action is unknown.');
282
			}
283
284
			$revisions_by_path = array_intersect(
285
				$revisions_by_path,
286
				$this->_revisionLog->find('paths', 'action:' . $action)
287
			);
288
		}
289
290
		$kind = $this->io->getOption('kind');
291
292
		if ( $kind ) {
293
			if ( !in_array($kind, $this->getAllKinds()) ) {
294
				throw new CommandException('The "' . $kind . '" kind is unknown.');
295
			}
296
297
			$revisions_by_path = array_intersect(
298
				$revisions_by_path,
299
				$this->_revisionLog->find('paths', 'kind:' . $kind)
300
			);
301
		}
302
303
		if ( $missing_revisions ) {
304
			throw new CommandException($this->getMissingRevisionsErrorMessage($missing_revisions));
305
		}
306
		elseif ( !$revisions_by_path ) {
307
			throw new CommandException('No matching revisions found.');
308
		}
309
310
		rsort($revisions_by_path, SORT_NUMERIC);
311
312
		if ( $bugs || $revisions ) {
313
			// Don't limit revisions, when provided explicitly by user.
314
			$revisions_by_path_with_limit = $revisions_by_path;
315
		}
316
		else {
317
			// Apply limit only, when no explicit bugs/revisions are set.
318
			$revisions_by_path_with_limit = array_slice($revisions_by_path, 0, $this->getMaxCount());
319
		}
320
321
		$revisions_by_path_count = count($revisions_by_path);
322
		$revisions_by_path_with_limit_count = count($revisions_by_path_with_limit);
323
324
		if ( $revisions_by_path_with_limit_count === $revisions_by_path_count ) {
325
			$this->io->writeln(sprintf(
326
				' * Showing <info>%d</info> revision(-s) in %s:',
327
				$revisions_by_path_with_limit_count,
328
				$this->getRevisionLogIdentifier()
329
			));
330
		}
331
		else {
332
			$this->io->writeln(sprintf(
333
				' * Showing <info>%d</info> of <info>%d</info> revision(-s) in %s:',
334
				$revisions_by_path_with_limit_count,
335
				$revisions_by_path_count,
336
				$this->getRevisionLogIdentifier()
337
			));
338
		}
339
340
		$this->printRevisions($revisions_by_path_with_limit);
341
342
		$this->io->writeln(
343
			'Results prepared in <info>' . round(microtime(true) - $profiling_start, 2) . 's</info>.'
344
		);
345
	}
346
347
	/**
348
	 * Returns all actions.
349
	 *
350
	 * @return array
351
	 */
352
	protected function getAllActions()
353
	{
354
		return array('A', 'M', 'R', 'D');
355
	}
356
357
	/**
358
	 * Returns all actions.
359
	 *
360
	 * @return array
361
	 */
362
	protected function getAllKinds()
363
	{
364
		return array('dir', 'file');
365
	}
366
367
	/**
368
	 * Returns revision log identifier.
369
	 *
370
	 * @return string
371
	 */
372
	protected function getRevisionLogIdentifier()
373
	{
374
		$ret = '<info>' . $this->_revisionLog->getProjectPath() . '</info> project';
375
376
		$ref_name = $this->_revisionLog->getRefName();
377
378
		if ( $ref_name ) {
379
			$ret .= ' (ref: <info>' . $ref_name . '</info>)';
380
		}
381
		else {
382
			$ret .= ' (all refs)';
383
		}
384
385
		return $ret;
386
	}
387
388
	/**
389
	 * Shows error about missing revisions.
390
	 *
391
	 * @param array $missing_revisions Missing revisions.
392
	 *
393
	 * @return string
394
	 */
395
	protected function getMissingRevisionsErrorMessage(array $missing_revisions)
396
	{
397
		$refs = $this->io->getOption('refs');
398
		$missing_revisions = implode(', ', $missing_revisions);
399
400
		if ( $refs ) {
401
			$revision_source = 'in "' . $refs . '" ref(-s)';
402
		}
403
		else {
404
			$revision_source = 'at "' . $this->getWorkingCopyUrl() . '" url';
405
		}
406
407
		return 'The ' . $missing_revisions . ' revision(-s) not found ' . $revision_source . '.';
408
	}
409
410
	/**
411
	 * Returns list of revisions by path.
412
	 *
413
	 * @return array
414
	 * @throws CommandException When given path doesn't exist.
415
	 * @throws CommandException When given refs doesn't exist.
416
	 */
417
	protected function getRevisionsByPath()
418
	{
419
		// When "$path" points to deleted path the "$wc_path" will be parent folder of it (hopefully existing folder).
420
		$path = $this->io->getArgument('path');
421
		$wc_path = $this->getWorkingCopyPath();
422
423
		$refs = $this->getList($this->io->getOption('refs'));
424
		$relative_path = $this->repositoryConnector->getRelativePath($wc_path);
425
426
		if ( !$this->repositoryConnector->isUrl($wc_path) ) {
427
			$relative_path .= $this->_getPathDifference($wc_path, $path);
428
		}
429
430
		$relative_path = $this->_crossReferencePathFromRepository($relative_path);
431
432
		if ( $relative_path === null ) {
433
			throw new CommandException(
434
				'The "' . $path . '" path not found in "' . $this->_revisionLog->getProjectPath() . '" project.'
435
			);
436
		}
437
438
		if ( $refs ) {
439
			$incorrect_refs = array_diff($refs, $this->getAllRefs());
440
441
			if ( $incorrect_refs ) {
442
				throw new CommandException(
443
					'The following refs are unknown: "' . implode('", "', $incorrect_refs) . '".'
444
				);
445
			}
446
447
			return $this->_revisionLog->find('refs', $refs);
448
		}
449
450
		return $this->_revisionLog->find('paths', $relative_path);
451
	}
452
453
	/**
454
	 * Returns difference between 2 paths.
455
	 *
456
	 * @param string $main_path Main path.
457
	 * @param string $sub_path  Sub path.
458
	 *
459
	 * @return string
460
	 */
461
	private function _getPathDifference($main_path, $sub_path)
462
	{
463
		if ( $sub_path === '.' || strpos($sub_path, '../') !== false ) {
464
			$sub_path = realpath($sub_path);
465
		}
466
467
		$adapted_sub_path = $sub_path;
468
469
		do {
470
			$sub_path_pos = strpos($main_path, $adapted_sub_path);
471
472
			if ( $sub_path_pos !== false ) {
473
				break;
474
			}
475
476
			$adapted_sub_path = dirname($adapted_sub_path);
477
		} while ( $adapted_sub_path !== '.' );
478
479
		// No sub-matches.
480
		if ( !strlen($adapted_sub_path) ) {
481
			return '';
482
		}
483
484
		return str_replace($adapted_sub_path, '', $sub_path);
485
	}
486
487
	/**
488
	 * Determines path kind from repository.
489
	 *
490
	 * @param string $path Path.
491
	 *
492
	 * @return string|null
493
	 */
494
	private function _crossReferencePathFromRepository($path)
495
	{
496
		$path = rtrim($path, '/');
497
		$try_paths = array($path, $path . '/');
498
499
		foreach ( $try_paths as $try_path ) {
500
			if ( $this->_revisionLog->find('paths', 'exact:' . $try_path) ) {
501
				return $try_path;
502
			}
503
		}
504
505
		return null;
506
	}
507
508
	/**
509
	 * Returns displayed revision limit.
510
	 *
511
	 * @return integer
512
	 */
513
	protected function getMaxCount()
514
	{
515
		$max_count = $this->io->getOption('max-count');
516
517
		if ( $max_count !== null ) {
518
			return $max_count;
519
		}
520
521
		return $this->getSetting(self::SETTING_LOG_LIMIT);
522
	}
523
524
	/**
525
	 * Prints revisions.
526
	 *
527
	 * @param array $revisions Revisions.
528
	 *
529
	 * @return void
530
	 */
531
	protected function printRevisions(array $revisions)
532
	{
533
		$column_mapping = array(
534
			'with-details' => RevisionPrinter::COLUMN_DETAILS,
535
			'with-summary' => RevisionPrinter::COLUMN_SUMMARY,
536
			'with-refs' => RevisionPrinter::COLUMN_REFS,
537
			'with-merge-oracle' => RevisionPrinter::COLUMN_MERGE_ORACLE,
538
			'with-merge-status' => RevisionPrinter::COLUMN_MERGE_STATUS,
539
		);
540
541
		foreach ( $column_mapping as $option_name => $column ) {
542
			if ( $this->io->getOption($option_name) ) {
543
				$this->_revisionPrinter->withColumn($column);
544
			}
545
		}
546
547
		$this->_revisionPrinter->setMergeConflictRegExps($this->getSetting(self::SETTING_LOG_MERGE_CONFLICT_REGEXPS));
548
		$this->_revisionPrinter->setLogMessageLimit($this->getSetting(self::SETTING_LOG_MESSAGE_LIMIT));
549
550
		$this->_revisionPrinter->printRevisions($this->_revisionLog, $revisions, $this->io->getOutput());
551
	}
552
553
	/**
554
	 * Returns list of config settings.
555
	 *
556
	 * @return AbstractConfigSetting[]
557
	 */
558
	public function getConfigSettings()
559
	{
560
		return array(
561
			new IntegerConfigSetting(self::SETTING_LOG_LIMIT, 10),
562
			new IntegerConfigSetting(self::SETTING_LOG_MESSAGE_LIMIT, 68),
563
			new RegExpsConfigSetting(self::SETTING_LOG_MERGE_CONFLICT_REGEXPS, '#/composer\.lock$#'),
564
		);
565
	}
566
567
}
568