Completed
Push — master ( 3c513e...852a18 )
by Alexander
03:56
created

LogCommand::generateChangeSummary()   B

Complexity

Conditions 6
Paths 16

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 28
ccs 0
cts 19
cp 0
rs 8.439
cc 6
eloc 15
nc 16
nop 1
crap 42
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\Helper\DateHelper;
19
use ConsoleHelpers\SVNBuddy\Repository\Parser\RevisionListParser;
20
use ConsoleHelpers\SVNBuddy\Repository\RevisionLog\RevisionLog;
21
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
22
use Symfony\Component\Console\Helper\Table;
23
use Symfony\Component\Console\Helper\TableCell;
24
use Symfony\Component\Console\Helper\TableSeparator;
25
use Symfony\Component\Console\Input\InputArgument;
26
use Symfony\Component\Console\Input\InputInterface;
27
use Symfony\Component\Console\Input\InputOption;
28
use Symfony\Component\Console\Output\OutputInterface;
29
30
class LogCommand extends AbstractCommand implements IAggregatorAwareCommand, IConfigAwareCommand
31
{
32
33
	const SETTING_LOG_LIMIT = 'log.limit';
34
35
	const SETTING_LOG_MERGE_CONFLICT_REGEXPS = 'log.merge-conflict-regexps';
36
37
	/**
38
	 * Revision list parser.
39
	 *
40
	 * @var RevisionListParser
41
	 */
42
	private $_revisionListParser;
43
44
	/**
45
	 * Revision log
46
	 *
47
	 * @var RevisionLog
48
	 */
49
	private $_revisionLog;
50
51
	/**
52
	 * Prepare dependencies.
53
	 *
54
	 * @return void
55
	 */
56
	protected function prepareDependencies()
57
	{
58
		parent::prepareDependencies();
59
60
		$container = $this->getContainer();
61
62
		$this->_revisionListParser = $container['revision_list_parser'];
63
	}
64
65
	/**
66
	 * {@inheritdoc}
0 ignored issues
show
introduced by
Doc comment short description must start with a capital letter
Loading history...
67
	 */
0 ignored issues
show
introduced by
Missing @return tag in function comment
Loading history...
68
	protected function configure()
69
	{
70
		$this->pathAcceptsUrl = true;
71
72
		$description = <<<TEXT
73
TODO
74
TEXT;
75
76
		$this
77
			->setName('log')
78
			->setDescription(
79
				'Show the log messages for revisions/bugs/path'
80
			)
81
			->setHelp($description)
82
			->addArgument(
83
				'path',
84
				InputArgument::OPTIONAL,
85
				'Working copy path or URL',
86
				'.'
87
			)
88
			->addOption(
89
				'revisions',
90
				'r',
91
				InputOption::VALUE_REQUIRED,
92
				'Revision or revision range (e.g. "53324,34342,1224-4433,232")'
93
			)
94
			->addOption(
95
				'bugs',
96
				'b',
97
				InputOption::VALUE_REQUIRED,
98
				'Bugs to merge (e.g. "JRA-1234,43644")'
99
			)
100
			->addOption(
101
				'refs',
102
				null,
103
				InputOption::VALUE_REQUIRED,
104
				'Refs (e.g. "trunk", "branches/branch-name", "tags/tag-name")'
105
			)
106
			->addOption(
107
				'details',
108
				'd',
109
				InputOption::VALUE_NONE,
110
				'Shows paths affected in each revision'
111
			)
112
			->addOption(
113
				'summary',
114
				's',
115
				InputOption::VALUE_NONE,
116
				'Shows summary of paths affected in each revision'
117
			)
118
			->addOption(
119
				'merge-oracle',
120
				null,
121
				InputOption::VALUE_NONE,
122
				'Detects commits with possible merge conflicts'
123
			)
124
			->addOption(
125
				'merges',
126
				null,
127
				InputOption::VALUE_NONE,
128
				'Print only merge commits'
129
			)
130
			->addOption(
131
				'no-merges',
132
				null,
133
				InputOption::VALUE_NONE,
134
				'Do not print merge commits'
135
			)
136
			->addOption(
137
				'merged',
138
				null,
139
				InputOption::VALUE_NONE,
140
				'Print only merged commits'
141
			)
142
			->addOption(
143
				'merged-by',
144
				null,
145
				InputOption::VALUE_REQUIRED,
146
				'Show revisions merged via given revision(-s)'
147
			)
148
			->addOption(
149
				'not-merged',
150
				null,
151
				InputOption::VALUE_NONE,
152
				'Print only not merged commits'
153
			)
154
			->addOption(
155
				'merge-status',
156
				null,
157
				InputOption::VALUE_NONE,
158
				'Show merge revisions (if any) for each revisions'
159
			)
160
			->addOption(
161
				'limit',
162
				null,
163
				InputOption::VALUE_REQUIRED,
164
				'Maximum number of log entries'
165
			);
166
167
		parent::configure();
168
	}
169
170
	/**
171
	 * Return possible values for the named option
172
	 *
173
	 * @param string            $optionName Option name.
174
	 * @param CompletionContext $context    Completion context.
175
	 *
176
	 * @return array
177
	 */
178
	public function completeOptionValues($optionName, CompletionContext $context)
179
	{
180
		$ret = parent::completeOptionValues($optionName, $context);
181
182
		if ( $optionName === 'refs' ) {
183
			return $this->getAllRefs();
184
		}
185
186
		return $ret;
187
	}
188
189
	/**
0 ignored issues
show
introduced by
Doc comment for parameter "$input" missing
Loading history...
introduced by
Doc comment for parameter "$output" missing
Loading history...
190
	 * {@inheritdoc}
191
	 */
192
	public function initialize(InputInterface $input, OutputInterface $output)
193
	{
194
		parent::initialize($input, $output);
195
196
		$this->_revisionLog = $this->getRevisionLog($this->getWorkingCopyUrl());
197
	}
198
199
	/**
200
	 * {@inheritdoc}
0 ignored issues
show
introduced by
Doc comment short description must start with a capital letter
Loading history...
201
	 */
0 ignored issues
show
Coding Style Documentation introduced by
Missing @throws tag in function comment
Loading history...
introduced by
Missing @return tag in function comment
Loading history...
202
	protected function execute(InputInterface $input, OutputInterface $output)
203
	{
204
		$bugs = $this->getList($this->io->getOption('bugs'));
205
		$revisions = $this->getList($this->io->getOption('revisions'));
206
207
		if ( $bugs && $revisions ) {
208
			throw new \RuntimeException('The "--bugs" and "--revisions" options are mutually exclusive.');
209
		}
210
211
		$missing_revisions = array();
212
		$revisions_by_path = $this->getRevisionsByPath();
213
214
		if ( $revisions ) {
215
			$revisions = $this->_revisionListParser->expandRanges($revisions);
216
			$revisions_by_path = array_intersect($revisions_by_path, $revisions);
217
			$missing_revisions = array_diff($revisions, $revisions_by_path);
218
		}
219
		elseif ( $bugs ) {
220
			// Only show bug-related revisions on given path. The $missing_revisions is always empty.
221
			$revisions_from_bugs = $this->_revisionLog->find('bugs', $bugs);
222
			$revisions_by_path = array_intersect($revisions_by_path, $revisions_from_bugs);
223
		}
224
225
		$merged_by = $this->getList($this->io->getOption('merged-by'));
226
227
		if ( $merged_by ) {
228
			// Exclude revisions, that were merged outside of project root folder in repository.
229
			$merged_by = $this->_revisionListParser->expandRanges($merged_by);
230
			$revisions_by_path = $this->_revisionLog->find(
231
				'paths',
232
				$this->repositoryConnector->getProjectUrl(
233
					$this->repositoryConnector->getRelativePath($this->getWorkingCopyPath())
234
				)
235
			);
236
			$revisions_by_path = array_intersect($revisions_by_path, $this->_revisionLog->find('merges', $merged_by));
237
		}
238
239
		if ( $this->io->getOption('merges') ) {
240
			$revisions_by_path = array_intersect($revisions_by_path, $this->_revisionLog->find('merges', 'all_merges'));
241
		}
242
		elseif ( $this->io->getOption('no-merges') ) {
243
			$revisions_by_path = array_diff($revisions_by_path, $this->_revisionLog->find('merges', 'all_merges'));
244
		}
245
246
		if ( $this->io->getOption('merged') ) {
247
			$revisions_by_path = array_intersect($revisions_by_path, $this->_revisionLog->find('merges', 'all_merged'));
248
		}
249
		elseif ( $this->io->getOption('not-merged') ) {
250
			$revisions_by_path = array_diff($revisions_by_path, $this->_revisionLog->find('merges', 'all_merged'));
251
		}
252
253
		if ( $missing_revisions ) {
254
			throw new CommandException(
255
				'No information about ' . implode(', ', $missing_revisions) . ' revision(-s).'
256
			);
257
		}
258
		elseif ( !$revisions_by_path ) {
259
			throw new CommandException('No matching revisions found.');
260
		}
261
262
		rsort($revisions_by_path, SORT_NUMERIC);
263
264
		if ( $bugs || $revisions ) {
265
			// Don't limit revisions, when provided explicitly by user.
266
			$revisions_by_path_with_limit = $revisions_by_path;
267
		}
268
		else {
269
			// Apply limit only, when no explicit bugs/revisions are set.
270
			$revisions_by_path_with_limit = array_slice($revisions_by_path, 0, $this->getLimit());
271
		}
272
273
		$revisions_by_path_count = count($revisions_by_path);
274
		$revisions_by_path_with_limit_count = count($revisions_by_path_with_limit);
275
276
		$this->io->writeln(sprintf(
277
			' * Showing <info>%d</info> of <info>%d</info> revision(-s):',
278
			$revisions_by_path_with_limit_count,
279
			$revisions_by_path_count
280
		));
281
282
		$this->printRevisions($revisions_by_path_with_limit, (boolean)$this->io->getOption('details'));
283
	}
284
285
	/**
286
	 * Returns list of revisions by path.
287
	 *
288
	 * @return array
289
	 * @throws CommandException When given refs doesn't exist.
290
	 */
291
	protected function getRevisionsByPath()
292
	{
293
		$refs = $this->getList($this->io->getOption('refs'));
294
		$relative_path = $this->repositoryConnector->getRelativePath($this->getWorkingCopyPath());
295
296
		if ( !$refs ) {
297
			$ref = $this->repositoryConnector->getRefByPath($relative_path);
298
299
			// Use search by ref, when working copy represents ref root folder.
300
			if ( $ref !== false && preg_match('/' . preg_quote($ref, '/') . '$/', $relative_path) ) {
301
				return $this->_revisionLog->find('refs', $ref);
302
			}
303
		}
304
305
		if ( $refs ) {
306
			$incorrect_refs = array_diff($refs, $this->getAllRefs());
307
308
			if ( $incorrect_refs ) {
309
				throw new CommandException(
310
					'The following refs are unknown: "' . implode('", "', $incorrect_refs) . '".'
311
				);
312
			}
313
314
			return $this->_revisionLog->find('refs', $refs);
315
		}
316
317
		return $this->_revisionLog->find('paths', $relative_path);
318
	}
319
320
	/**
321
	 * Returns displayed revision limit.
322
	 *
323
	 * @return integer
324
	 */
325
	protected function getLimit()
326
	{
327
		$option_limit = $this->io->getOption('limit');
328
329
		if ( $option_limit !== null ) {
330
			return $option_limit;
331
		}
332
333
		return $this->getSetting(self::SETTING_LOG_LIMIT);
334
	}
335
336
	/**
337
	 * Prints revisions.
338
	 *
339
	 * @param array   $revisions    Revisions.
340
	 * @param boolean $with_details Print extended revision details (e.g. paths changed).
341
	 *
342
	 * @return void
343
	 */
344
	protected function printRevisions(array $revisions, $with_details = false)
345
	{
346
		$table = new Table($this->io->getOutput());
347
		$headers = array('Revision', 'Author', 'Date', 'Bug-ID', 'Log Message');
348
349
		// Add "Summary" header.
350
		if ( $this->io->getOption('summary') ) {
351
			$headers[] = 'Summary';
352
		}
353
354
		$merge_oracle = $this->io->getOption('merge-oracle');
355
356
		// Add "M.O." header.
357
		if ( $merge_oracle ) {
358
			$headers[] = 'M.O.';
359
			$merge_conflict_regexps = $this->getMergeConflictRegExps();
360
		}
361
362
		// Add "Merged Via" header.
363
		$merge_status = $this->io->getOption('merge-status');
364
365
		if ( $merge_status ) {
366
			$headers[] = 'Merged Via';
367
		}
368
369
		$table->setHeaders($headers);
370
371
		/** @var DateHelper $date_helper */
372
		$date_helper = $this->getHelper('date');
373
374
		$prev_bugs = null;
375
		$last_color = 'yellow';
376
		$last_revision = end($revisions);
377
378
		$repository_path = $this->repositoryConnector->getRelativePath(
379
			$this->getWorkingCopyPath()
380
		) . '/';
381
382
		foreach ( $revisions as $revision ) {
383
			$revision_data = $this->_revisionLog->getRevisionData('summary', $revision);
384
			list($log_message,) = explode(PHP_EOL, $revision_data['msg']);
385
			$log_message = preg_replace('/^\[fixes:.*?\]/', "\xE2\x9C\x94", $log_message);
386
387
			if ( mb_strlen($log_message) > 68 ) {
388
				$log_message = mb_substr($log_message, 0, 68 - 3) . '...';
389
			}
390
391
			$new_bugs = implode(', ', $this->_revisionLog->getRevisionData('bugs', $revision));
392
393
			if ( isset($prev_bugs) && $new_bugs <> $prev_bugs ) {
394
				$last_color = $last_color == 'yellow' ? 'magenta' : 'yellow';
395
			}
396
397
			$row = array(
398
				$revision,
399
				$revision_data['author'],
400
				$date_helper->getAgoTime($revision_data['date']),
401
				'<fg=' . $last_color . '>' . $new_bugs . '</>',
402
				$log_message,
403
			);
404
405
			$revision_paths = $this->_revisionLog->getRevisionData('paths', $revision);
406
407
			// Add "Summary" column.
408
			if ( $this->io->getOption('summary') ) {
409
				$row[] = $this->generateChangeSummary($revision_paths);
410
			}
411
412
			// Add "M.O." column.
413
			if ( $merge_oracle ) {
414
				$merge_conflict_predication = $this->getMergeConflictPrediction(
415
					$revision_paths,
416
					$merge_conflict_regexps
0 ignored issues
show
Bug introduced by
The variable $merge_conflict_regexps does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
417
				);
418
				$row[] = $merge_conflict_predication ? '<error>' . count($merge_conflict_predication) . '</error>' : '';
419
			}
420
			else {
421
				$merge_conflict_predication = array();
422
			}
423
424
			// Add "Merged Via" column.
425
			if ( $merge_status ) {
426
				$merged_via = $this->_revisionLog->getRevisionData('merges', $revision);
427
				$row[] = $merged_via ? implode(', ', $merged_via) : '';
428
			}
429
430
			$table->addRow($row);
431
432
			if ( $with_details ) {
433
				$details = '<fg=white;options=bold>Changed Paths:</>';
434
435
				foreach ( $revision_paths as $path_data ) {
436
					$path_action = $path_data['action'];
437
					$relative_path = $this->_getRelativeLogPath($path_data, 'path', $repository_path);
438
439
					$details .= PHP_EOL . ' * ';
440
441
					if ( $path_action == 'A' ) {
442
						$color_format = 'fg=green';
443
					}
444
					elseif ( $path_action == 'D' ) {
445
						$color_format = 'fg=red';
446
					}
447
					else {
448
						$color_format = in_array($path_data['path'], $merge_conflict_predication) ? 'error' : '';
449
					}
450
451
					$to_colorize = $path_action . '    ' . $relative_path;
452
453
					if ( isset($path_data['copyfrom-path']) ) {
454
						$copy_from_rev = $path_data['copyfrom-rev'];
455
						$copy_from_path = $this->_getRelativeLogPath($path_data, 'copyfrom-path', $repository_path);
456
						$to_colorize .= PHP_EOL . '        (from ' . $copy_from_path . ':' . $copy_from_rev . ')';
457
					}
458
459
					if ( $color_format ) {
460
						$to_colorize = '<' . $color_format . '>' . $to_colorize . '</>';
461
					}
462
463
					$details .= $to_colorize;
464
				}
465
466
				$table->addRow(new TableSeparator());
467
				$table->addRow(array(new TableCell($details, array('colspan' => 5))));
468
469
				if ( $revision != $last_revision ) {
470
					$table->addRow(new TableSeparator());
471
				}
472
			}
473
474
			$prev_bugs = $new_bugs;
475
		}
476
477
		$table->render();
478
	}
479
480
	/**
481
	 * Generates change summary for a revision.
482
	 *
483
	 * @param array $revision_paths Revision paths.
484
	 *
485
	 * @return string
486
	 */
487
	protected function generateChangeSummary(array $revision_paths)
488
	{
489
		$summary = array('added' => 0, 'changed' => 0, 'removed' => 0);
490
491
		foreach ( $revision_paths as $path_data ) {
492
			$path_action = $path_data['action'];
493
494
			if ( $path_action == 'A' ) {
495
				$summary['added']++;
496
			}
497
			elseif ( $path_action == 'D' ) {
498
				$summary['removed']++;
499
			}
500
			else {
501
				$summary['changed']++;
502
			}
503
		}
504
505
		if ( $summary['added'] ) {
506
			$summary['added'] = '<fg=green>+' . $summary['added'] . '</>';
507
		}
508
509
		if ( $summary['removed'] ) {
510
			$summary['removed'] = '<fg=red>-' . $summary['removed'] . '</>';
511
		}
512
513
		return implode(' ', array_filter($summary));
514
	}
515
516
	/**
517
	 * Returns merge conflict path predictions.
518
	 *
519
	 * @param array $revision_paths         Revision paths.
520
	 * @param array $merge_conflict_regexps Merge conflict paths.
521
	 *
522
	 * @return array
523
	 */
524
	protected function getMergeConflictPrediction(array $revision_paths, array $merge_conflict_regexps)
525
	{
526
		if ( !$merge_conflict_regexps ) {
527
			return array();
528
		}
529
530
		$conflict_paths = array();
531
532
		foreach ( $revision_paths as $revision_path ) {
533
			foreach ( $merge_conflict_regexps as $merge_conflict_regexp ) {
534
				if ( preg_match($merge_conflict_regexp, $revision_path['path']) ) {
535
					$conflict_paths[] = $revision_path['path'];
536
				}
537
			}
538
		}
539
540
		return $conflict_paths;
541
	}
542
543
	/**
544
	 * Returns merge conflict regexps.
545
	 *
546
	 * @return array
547
	 */
548
	protected function getMergeConflictRegExps()
549
	{
550
		return $this->getSetting(self::SETTING_LOG_MERGE_CONFLICT_REGEXPS);
551
	}
552
553
	/**
554
	 * Returns relative path to "svn log" returned path.
555
	 *
556
	 * @param array  $path_data       Path data.
557
	 * @param string $path_key        Path key.
558
	 * @param string $repository_path Repository path.
559
	 *
560
	 * @return string
561
	 */
562
	private function _getRelativeLogPath(array $path_data, $path_key, $repository_path)
563
	{
564
		$ret = $path_data[$path_key];
565
566
		if ( $path_data['kind'] == 'dir' ) {
567
			$ret .= '/';
568
		}
569
570
		$ret = preg_replace('/^' . preg_quote($repository_path, '/') . '/', '', $ret, 1);
571
572
		if ( $ret === '' ) {
573
			$ret = '.';
574
		}
575
576
		return $ret;
577
	}
578
579
	/**
580
	 * Returns URL to the working copy.
581
	 *
582
	 * @return string
583
	 */
584
	protected function getWorkingCopyUrl()
585
	{
586
		$wc_path = $this->getWorkingCopyPath();
587
588
		if ( !$this->repositoryConnector->isUrl($wc_path) ) {
589
			return $this->repositoryConnector->getWorkingCopyUrl($wc_path);
590
		}
591
592
		return $wc_path;
593
	}
594
595
	/**
596
	 * Returns list of config settings.
597
	 *
598
	 * @return AbstractConfigSetting[]
599
	 */
600
	public function getConfigSettings()
601
	{
602
		return array(
603
			new IntegerConfigSetting(self::SETTING_LOG_LIMIT, 10),
604
			new RegExpsConfigSetting(self::SETTING_LOG_MERGE_CONFLICT_REGEXPS, '#/composer\.lock$#'),
605
		);
606
	}
607
608
}
609