Completed
Push — master ( 165a98...7e05e5 )
by Alexander
02:32
created

MergeCommand::ensureWorkingCopyWithoutConflicts()   C

Complexity

Conditions 7
Paths 7

Size

Total Lines 51
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 51
ccs 0
cts 34
cp 0
rs 6.9743
cc 7
eloc 29
nc 7
nop 3
crap 56

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\ArrayConfigSetting;
16
use ConsoleHelpers\SVNBuddy\Config\StringConfigSetting;
17
use ConsoleHelpers\ConsoleKit\Exception\CommandException;
18
use ConsoleHelpers\SVNBuddy\MergeSourceDetector\AbstractMergeSourceDetector;
19
use ConsoleHelpers\SVNBuddy\Repository\Parser\RevisionListParser;
20
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
21
use Symfony\Component\Console\Helper\Table;
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 MergeCommand extends AbstractCommand implements IAggregatorAwareCommand, IConfigAwareCommand
28
{
29
30
	const SETTING_MERGE_SOURCE_URL = 'merge.source-url';
31
32
	const SETTING_MERGE_RECENT_CONFLICTS = 'merge.recent-conflicts';
33
34
	const REVISION_ALL = 'all';
35
36
	/**
37
	 * Merge source detector.
38
	 *
39
	 * @var AbstractMergeSourceDetector
40
	 */
41
	private $_mergeSourceDetector;
42
43
	/**
44
	 * Revision list parser.
45
	 *
46
	 * @var RevisionListParser
47
	 */
48
	private $_revisionListParser;
49
50
	/**
51
	 * Unmerged revisions.
52
	 *
53
	 * @var array
54
	 */
55
	private $_unmergedRevisions = array();
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->_mergeSourceDetector = $container['merge_source_detector'];
69
		$this->_revisionListParser = $container['revision_list_parser'];
70
	}
71
72
	/**
73
	 * {@inheritdoc}
0 ignored issues
show
introduced by
Doc comment short description must start with a capital letter
Loading history...
74
	 */
0 ignored issues
show
introduced by
Missing @return tag in function comment
Loading history...
75
	protected function configure()
76
	{
77
		$description = <<<TEXT
78
TODO
79
TEXT;
80
81
		$this
82
			->setName('merge')
83
			->setDescription('Applies the change from another source to a working copy path')
84
			->setHelp($description)
85
			->addArgument(
86
				'path',
87
				InputArgument::OPTIONAL,
88
				'Working copy path',
89
				'.'
90
			)
91
			->addOption(
92
				'source-url',
93
				null,
94
				InputOption::VALUE_REQUIRED,
95
				'Source url'
96
			)
97
			->addOption(
98
				'revisions',
99
				'r',
100
				InputOption::VALUE_REQUIRED,
101
				'Revisions to merge (e.g. "53324,34342,1224-4433,232" or "all" to merge all)'
102
			)
103
			->addOption(
104
				'bugs',
105
				'b',
106
				InputOption::VALUE_REQUIRED,
107
				'Bugs to merge (e.g. "JRA-1234,43644")'
108
			)
109
			->addOption(
110
				'details',
111
				'd',
112
				InputOption::VALUE_NONE,
113
				'Shows paths affected in each revision'
114
			)
115
			->addOption(
116
				'summary',
117
				's',
118
				InputOption::VALUE_NONE,
119
				'Shows summary of paths affected in each revision'
120
			)
121
			/*->addOption(
122
				'rollback',
123
				null,
124
				InputOption::VALUE_NONE,
125
				'Do a rollback merge'
126
			)
127
			->addOption(
128
				'record-only',
129
				null,
130
				InputOption::VALUE_NONE,
131
				'Only mark revisions as merged'
132
			)*/;
0 ignored issues
show
Coding Style introduced by
Space found before semicolon; expected ");" but found ")
/*->addOption(
'rollback',
null,
InputOption::VALUE_NONE,
'Do a rollback merge'
)
->addOption(
'record-only',
null,
InputOption::VALUE_NONE,
'Only mark revisions as merged'
)*/;"
Loading history...
133
134
		parent::configure();
135
	}
136
137
	/**
138
	 * Return possible values for the named option
139
	 *
140
	 * @param string            $optionName Option name.
141
	 * @param CompletionContext $context    Completion context.
142
	 *
143
	 * @return array
144
	 */
145
	public function completeOptionValues($optionName, CompletionContext $context)
1 ignored issue
show
introduced by
Variable "optionName" is not in valid snake caps format
Loading history...
146
	{
147
		$ret = parent::completeOptionValues($optionName, $context);
1 ignored issue
show
introduced by
Variable "optionName" is not in valid snake caps format
Loading history...
148
149
		if ( $optionName === 'revisions' ) {
1 ignored issue
show
introduced by
Variable "optionName" is not in valid snake caps format
Loading history...
150
			return array('all');
151
		}
152
153
		return $ret;
154
	}
155
156
	/**
0 ignored issues
show
introduced by
Doc comment for parameter "$input" missing
Loading history...
introduced by
Doc comment for parameter "$output" missing
Loading history...
157
	 * {@inheritdoc}
0 ignored issues
show
introduced by
Doc comment short description must start with a capital letter
Loading history...
158
	 */
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...
159
	protected function execute(InputInterface $input, OutputInterface $output)
160
	{
161
		$bugs = $this->getList($this->io->getOption('bugs'));
162
		$revisions = $this->getList($this->io->getOption('revisions'));
163
164
		if ( $bugs && $revisions ) {
165
			throw new \RuntimeException('The "--bugs" and "--revisions" options are mutually exclusive.');
166
		}
167
168
		$wc_path = $this->getWorkingCopyPath();
169
170
		$this->ensureLatestWorkingCopy($wc_path);
171
172
		$source_url = $this->getSourceUrl($wc_path);
173
		$this->printSourceAndTarget($source_url, $wc_path);
174
		$this->_unmergedRevisions = $this->getUnmergedRevisions($source_url, $wc_path);
175
176
		if ( ($bugs || $revisions) && !$this->_unmergedRevisions ) {
177
			throw new CommandException('Nothing to merge.');
178
		}
179
180
		$this->ensureWorkingCopyWithoutConflicts($source_url, $wc_path);
181
182
		if ( $this->shouldMergeAll($revisions) ) {
183
			$revisions = $this->_unmergedRevisions;
184
		}
185
		else {
186
			if ( $revisions ) {
187
				$revisions = $this->getDirectRevisions($revisions, $source_url);
188
			}
189
			elseif ( $bugs ) {
190
				$revisions = $this->getRevisionLog($source_url)->find('bugs', $bugs);
191
			}
192
193
			if ( $revisions ) {
194
				$revisions = array_intersect($revisions, $this->_unmergedRevisions);
195
196
				if ( !$revisions ) {
197
					throw new CommandException('Requested revisions are already merged');
198
				}
199
			}
200
		}
201
202
		if ( $revisions ) {
203
			$this->performMerge($source_url, $wc_path, $revisions);
204
		}
205
		elseif ( $this->_unmergedRevisions ) {
206
			$this->runOtherCommand('log', array(
207
				'path' => $this->repositoryConnector->getProjectUrl($source_url),
208
				'--revisions' => implode(',', $this->_unmergedRevisions),
209
				'--details' => $this->io->getOption('details'),
210
				'--summary' => $this->io->getOption('summary'),
211
				'--merge-oracle' => true,
212
			));
213
		}
214
	}
215
216
	/**
217
	 * Determines if all unmerged revisions should be merged.
218
	 *
219
	 * @param array $revisions Revisions.
220
	 *
221
	 * @return boolean
222
	 */
223
	protected function shouldMergeAll(array $revisions)
224
	{
225
		return $revisions === array(self::REVISION_ALL);
226
	}
227
228
	/**
229
	 * Ensures, that working copy is up to date.
230
	 *
231
	 * @param string $wc_path Working copy path.
232
	 *
233
	 * @return void
234
	 * @throws CommandException When working copy is out of date.
0 ignored issues
show
introduced by
@throws tag found, but no exceptions are thrown by the function
Loading history...
235
	 */
236
	protected function ensureLatestWorkingCopy($wc_path)
237
	{
238
		$this->io->write(' * Working Copy Status ... ');
239
240
		if ( $this->repositoryConnector->isMixedRevisionWorkingCopy($wc_path) ) {
241
			$this->io->writeln('<error>Mixed revisions</error>');
242
			$this->updateWorkingCopy($wc_path);
243
244
			return;
245
		}
246
247
		$working_copy_revision = $this->repositoryConnector->getLastRevision($wc_path);
248
		$repository_revision = $this->repositoryConnector->getLastRevision(
249
			$this->repositoryConnector->getWorkingCopyUrl($wc_path)
250
		);
251
252
		if ( $repository_revision > $working_copy_revision ) {
253
			$this->io->writeln('<error>Out of date</error>');
254
			$this->updateWorkingCopy($wc_path);
255
256
			return;
257
		}
258
259
		$this->io->writeln('<info>Up to date</info>');
260
	}
261
262
	/**
263
	 * Updates working copy.
264
	 *
265
	 * @param string $wc_path Working copy path.
266
	 *
267
	 * @return void
268
	 * @throws CommandException When unable to perform an update.
0 ignored issues
show
introduced by
@throws tag found, but no exceptions are thrown by the function
Loading history...
269
	 */
270
	protected function updateWorkingCopy($wc_path)
271
	{
272
		$this->runOtherCommand('update', array('path' => $wc_path));
273
	}
274
275
	/**
276
	 * Returns source url for merge.
277
	 *
278
	 * @param string $wc_path Working copy path.
279
	 *
280
	 * @return string
281
	 * @throws CommandException When source path is invalid.
282
	 */
283
	protected function getSourceUrl($wc_path)
284
	{
285
		$source_url = $this->io->getOption('source-url');
286
287
		if ( $source_url === null ) {
288
			$source_url = $this->getSetting(self::SETTING_MERGE_SOURCE_URL);
289
		}
290
291
		if ( !$source_url ) {
292
			$wc_url = $this->repositoryConnector->getWorkingCopyUrl($wc_path);
293
			$source_url = $this->_mergeSourceDetector->detect($wc_url);
294
295
			if ( $source_url ) {
296
				$this->setSetting(self::SETTING_MERGE_SOURCE_URL, $source_url);
297
			}
298
		}
299
300
		if ( !$source_url ) {
301
			$error_msg = 'Unable to determine source url for merge.' . PHP_EOL;
302
			$error_msg .= 'Please specify it manually using "--source-url" option';
303
			throw new CommandException($error_msg);
304
		}
305
306
		return $source_url;
307
	}
308
309
	/**
310
	 * Prints information about merge source & target.
311
	 *
312
	 * @param string $source_url Merge source: url.
313
	 * @param string $wc_path    Merge target: working copy path.
314
	 *
315
	 * @return void
316
	 */
317
	protected function printSourceAndTarget($source_url, $wc_path)
318
	{
319
		$relative_source_url = $this->repositoryConnector->getPathFromUrl($source_url);
320
		$relative_target_url = $this->repositoryConnector->getPathFromUrl(
321
			$this->repositoryConnector->getWorkingCopyUrl($wc_path)
322
		);
323
324
		$this->io->writeln(' * Merge Source ... <info>' . $relative_source_url . '</info>');
325
		$this->io->writeln(' * Merge Target ... <info>' . $relative_target_url . '</info>');
326
	}
327
328
	/**
329
	 * Ensures, that there are some unmerged revisions.
330
	 *
331
	 * @param string $source_url Merge source: url.
332
	 * @param string $wc_path    Merge target: working copy path.
333
	 *
334
	 * @return array
335
	 */
336
	protected function getUnmergedRevisions($source_url, $wc_path)
337
	{
338
		// Avoid missing revision query progress bar overwriting following output.
339
		$revision_log = $this->getRevisionLog($source_url);
340
341
		$this->io->write(' * Upcoming Merge Status ... ');
342
		$unmerged_revisions = $this->calculateUnmergedRevisions($source_url, $wc_path);
343
344
		if ( $unmerged_revisions ) {
345
			$unmerged_bugs = $revision_log->getBugsFromRevisions($unmerged_revisions);
346
			$error_msg = '<error>%d revision(-s) or %d bug(-s) not merged</error>';
347
			$this->io->writeln(sprintf($error_msg, count($unmerged_revisions), count($unmerged_bugs)));
348
		}
349
		else {
350
			$this->io->writeln('<info>Up to date</info>');
351
		}
352
353
		return $unmerged_revisions;
354
	}
355
356
	/**
357
	 * Returns not merged revisions.
358
	 *
359
	 * @param string $source_url Merge source: url.
360
	 * @param string $wc_path    Merge target: working copy path.
361
	 *
362
	 * @return array
363
	 */
364
	protected function calculateUnmergedRevisions($source_url, $wc_path)
365
	{
366
		$command = $this->repositoryConnector->getCommand(
367
			'mergeinfo',
368
			'--show-revs eligible {' . $source_url . '} {' . $wc_path . '}'
369
		);
370
371
		$merge_info = $this->repositoryConnector->getProperty('svn:mergeinfo', $wc_path);
372
373
		$cache_invalidator = array(
374
			'source:' . $this->repositoryConnector->getLastRevision($source_url),
375
			'merged_hash:' . crc32($merge_info),
376
		);
377
		$command->setCacheInvalidator(implode(';', $cache_invalidator));
378
379
		$merge_info = $command->run();
380
		$merge_info = explode(PHP_EOL, $merge_info);
381
382
		foreach ( $merge_info as $index => $revision ) {
383
			$merge_info[$index] = ltrim($revision, 'r');
384
		}
385
386
		return array_filter($merge_info);
387
	}
388
389
	/**
390
	 * Parses information from "svn:mergeinfo" property.
391
	 *
392
	 * @param string $source_path Merge source: path in repository.
393
	 * @param string $wc_path     Merge target: working copy path.
394
	 *
395
	 * @return array
396
	 */
397
	protected function getMergedRevisions($source_path, $wc_path)
398
	{
399
		$merge_info = $this->repositoryConnector->getProperty('svn:mergeinfo', $wc_path);
400
		$merge_info = array_filter(explode("\n", $merge_info));
401
402
		foreach ( $merge_info as $merge_info_line ) {
403
			list($path, $revisions) = explode(':', $merge_info_line, 2);
404
405
			if ( $path == $source_path ) {
406
				return $this->_revisionListParser->expandRanges(explode(',', $revisions));
407
			}
408
		}
409
410
		return array();
411
	}
412
413
	/**
414
	 * Validates revisions to actually exist.
415
	 *
416
	 * @param array  $revisions      Revisions.
417
	 * @param string $repository_url Repository url.
418
	 *
419
	 * @return array
420
	 * @throws CommandException When revision doesn't exist.
421
	 */
422
	protected function getDirectRevisions(array $revisions, $repository_url)
423
	{
424
		$revision_log = $this->getRevisionLog($repository_url);
425
426
		try {
427
			$revisions = $this->_revisionListParser->expandRanges($revisions);
428
429
			foreach ( $revisions as $revision ) {
430
				$revision_log->getRevisionData('summary', $revision);
431
			}
432
		}
433
		catch ( \InvalidArgumentException $e ) {
434
			throw new CommandException($e->getMessage());
435
		}
436
437
		return $revisions;
438
	}
439
440
	/**
441
	 * Performs merge.
442
	 *
443
	 * @param string $source_url Merge source: url.
444
	 * @param string $wc_path    Merge target: working copy path.
445
	 * @param array  $revisions  Revisions to merge.
446
	 *
447
	 * @return void
448
	 */
449
	protected function performMerge($source_url, $wc_path, array $revisions)
450
	{
451
		sort($revisions, SORT_NUMERIC);
452
453
		foreach ( $revisions as $revision ) {
454
			$command = $this->repositoryConnector->getCommand(
455
				'merge',
456
				'-c ' . $revision . ' {' . $source_url . '} {' . $wc_path . '}'
457
			);
458
459
			$merge_line = '--- Merging r' . $revision . " into '.':";
460
			$command->runLive(array(
461
				$wc_path => '.',
462
				$merge_line => '<fg=white;options=bold>' . $merge_line . '</>',
463
			));
464
465
			$this->_unmergedRevisions = array_diff($this->_unmergedRevisions, array($revision));
466
			$this->ensureWorkingCopyWithoutConflicts($source_url, $wc_path, $revision);
467
		}
468
	}
469
470
	/**
471
	 * Ensures, that there are no unresolved conflicts in working copy.
472
	 *
473
	 * @param string  $source_url                 Source url.
474
	 * @param string  $wc_path                    Working copy path.
475
	 * @param integer $largest_suggested_revision Largest revision, that is suggested in error message.
476
	 *
477
	 * @return void
478
	 * @throws CommandException When merge conflicts detected.
479
	 */
480
	protected function ensureWorkingCopyWithoutConflicts($source_url, $wc_path, $largest_suggested_revision = null)
481
	{
482
		$this->io->write(' * Previous Merge Status ... ');
483
484
		$conflicts = $this->repositoryConnector->getWorkingCopyConflicts($wc_path);
485
486
		if ( !$conflicts ) {
487
			$this->io->writeln('<info>Successful</info>');
488
489
			return;
490
		}
491
492
		$this->rememberConflicts($conflicts);
493
		$this->io->writeln('<error>' . count($conflicts) . ' conflict(-s)</error>');
494
495
		$table = new Table($this->io->getOutput());
496
497
		if ( $largest_suggested_revision ) {
498
			$table->setHeaders(array(
499
				'Path',
500
				'Associated Revisions (before ' . $largest_suggested_revision . ')',
501
			));
502
		}
503
		else {
504
			$table->setHeaders(array(
505
				'Path',
506
				'Associated Revisions',
507
			));
508
		}
509
510
		$revision_log = $this->getRevisionLog($source_url);
511
		$source_path = $this->repositoryConnector->getPathFromUrl($source_url) . '/';
512
513
		foreach ( $conflicts as $conflict_path ) {
514
			$path_revisions = $revision_log->find('paths', $source_path . $conflict_path);
515
			$path_revisions = array_intersect($this->_unmergedRevisions, $path_revisions);
516
517
			if ( $path_revisions && isset($largest_suggested_revision) ) {
518
				$path_revisions = $this->limitRevisions($path_revisions, $largest_suggested_revision);
519
			}
520
521
			$table->addRow(array(
522
				$conflict_path,
523
				$path_revisions ? implode(', ', $path_revisions) : '-',
524
			));
525
		}
526
527
		$table->render();
528
529
		throw new CommandException('Working copy contains unresolved merge conflicts.');
530
	}
531
532
	/**
533
	 * Adds new conflicts to already remembered ones.
534
	 *
535
	 * @param array $conflicts Conflicts.
536
	 *
537
	 * @return void
538
	 */
539
	protected function rememberConflicts(array $conflicts)
540
	{
541
		$previous_conflicts = $this->getSetting(self::SETTING_MERGE_RECENT_CONFLICTS);
542
		$new_conflicts = array_unique(array_merge($previous_conflicts, $conflicts));
543
544
		$this->setSetting(self::SETTING_MERGE_RECENT_CONFLICTS, $new_conflicts);
545
	}
546
547
	/**
548
	 * Returns revisions not larger, then given one.
549
	 *
550
	 * @param array   $revisions    Revisions.
551
	 * @param integer $max_revision Maximal revision.
552
	 *
553
	 * @return array
554
	 */
555
	protected function limitRevisions(array $revisions, $max_revision)
556
	{
557
		$ret = array();
558
559
		foreach ( $revisions as $revision ) {
560
			if ( $revision < $max_revision ) {
561
				$ret[] = $revision;
562
			}
563
		}
564
565
		return $ret;
566
	}
567
568
	/**
569
	 * Returns list of config settings.
570
	 *
571
	 * @return AbstractConfigSetting[]
572
	 */
573
	public function getConfigSettings()
574
	{
575
		return array(
576
			new StringConfigSetting(self::SETTING_MERGE_SOURCE_URL, ''),
577
			new ArrayConfigSetting(self::SETTING_MERGE_RECENT_CONFLICTS, array()),
578
		);
579
	}
580
581
}
582