Failed Conditions
Push — master ( c1c7d6...c5e998 )
by Alexander
03:04
created

MergeCommand::getMergedRevisions()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

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