Completed
Push — master ( 00486a...409d2f )
by Alexander
03:17
created

MergeCommand::getDirectRevisions()   A

Complexity

Conditions 2
Paths 3

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 14
ccs 0
cts 7
cp 0
rs 9.4285
cc 2
eloc 8
nc 3
nop 2
crap 6
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}
84
	 */
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
127
		parent::configure();
128
	}
129
130
	/**
131
	 * Return possible values for the named option
132
	 *
133
	 * @param string            $optionName Option name.
134
	 * @param CompletionContext $context    Completion context.
135
	 *
136
	 * @return array
137
	 */
138
	public function completeOptionValues($optionName, CompletionContext $context)
139
	{
140
		$ret = parent::completeOptionValues($optionName, $context);
141
142
		if ( $optionName === 'revisions' ) {
143
			return array('all');
144
		}
145
146
		if ( $optionName == 'source-url' ) {
147
			return $this->getAllRefs();
148
		}
149
150
		return $ret;
151
	}
152
153
	/**
154
	 * {@inheritdoc}
155
	 *
156
	 * @throws \RuntimeException When both "--bugs" and "--revisions" options were specified.
157
	 * @throws CommandException When everything is merged.
158
	 * @throws CommandException When manually specified revisions are already merged.
159
	 */
160
	protected function execute(InputInterface $input, OutputInterface $output)
161
	{
162
		$bugs = $this->getList($this->io->getOption('bugs'));
163
		$revisions = $this->getList($this->io->getOption('revisions'));
164
165
		if ( $bugs && $revisions ) {
166
			throw new \RuntimeException('The "--bugs" and "--revisions" options are mutually exclusive.');
167
		}
168
169
		$wc_path = $this->getWorkingCopyPath();
170
171
		$this->ensureLatestWorkingCopy($wc_path);
172
173
		$source_url = $this->getSourceUrl($wc_path);
174
		$this->printSourceAndTarget($source_url, $wc_path);
175
		$this->_unmergedRevisions = $this->getUnmergedRevisions($source_url, $wc_path);
176
177
		if ( ($bugs || $revisions) && !$this->_unmergedRevisions ) {
178
			throw new CommandException('Nothing to merge.');
179
		}
180
181
		$this->ensureWorkingCopyWithoutConflicts($source_url, $wc_path);
182
183
		if ( $this->shouldMergeAll($revisions) ) {
184
			$revisions = $this->_unmergedRevisions;
185
		}
186
		else {
187
			if ( $revisions ) {
188
				$revisions = $this->getDirectRevisions($revisions, $source_url);
189
			}
190
			elseif ( $bugs ) {
191
				$revisions = $this->getRevisionLog($source_url)->find('bugs', $bugs);
192
			}
193
194
			if ( $revisions ) {
195
				$revisions = array_intersect($revisions, $this->_unmergedRevisions);
196
197
				if ( !$revisions ) {
198
					throw new CommandException('Requested revisions are already merged');
199
				}
200
			}
201
		}
202
203
		if ( $revisions ) {
204
			$this->performMerge($source_url, $wc_path, $revisions);
205
		}
206
		elseif ( $this->_unmergedRevisions ) {
207
			$this->runOtherCommand('log', array(
208
				'path' => $this->repositoryConnector->getProjectUrl($source_url),
209
				'--revisions' => implode(',', $this->_unmergedRevisions),
210
				'--with-details' => $this->io->getOption('with-details'),
211
				'--with-summary' => $this->io->getOption('with-summary'),
212
				'--with-merge-oracle' => true,
213
			));
214
		}
215
	}
216
217
	/**
218
	 * Determines if all unmerged revisions should be merged.
219
	 *
220
	 * @param array $revisions Revisions.
221
	 *
222
	 * @return boolean
223
	 */
224
	protected function shouldMergeAll(array $revisions)
225
	{
226
		return $revisions === array(self::REVISION_ALL);
227
	}
228
229
	/**
230
	 * Ensures, that working copy is up to date.
231
	 *
232
	 * @param string $wc_path Working copy path.
233
	 *
234
	 * @return void
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
	 */
269
	protected function updateWorkingCopy($wc_path)
270
	{
271
		$this->runOtherCommand('update', array('path' => $wc_path));
272
	}
273
274
	/**
275
	 * Returns source url for merge.
276
	 *
277
	 * @param string $wc_path Working copy path.
278
	 *
279
	 * @return string
280
	 * @throws CommandException When source path is invalid.
281
	 */
282
	protected function getSourceUrl($wc_path)
283
	{
284
		$source_url = $this->io->getOption('source-url');
285
286
		if ( $source_url === null ) {
287
			$source_url = $this->getSetting(self::SETTING_MERGE_SOURCE_URL);
288
		}
289
		elseif ( !$this->repositoryConnector->isUrl($source_url) ) {
290
			$wc_url = $this->repositoryConnector->getWorkingCopyUrl($wc_path);
291
			$source_url = $this->_urlResolver->resolve($wc_url, $source_url);
292
		}
293
294
		if ( !$source_url ) {
295
			$wc_url = $this->repositoryConnector->getWorkingCopyUrl($wc_path);
296
			$source_url = $this->_mergeSourceDetector->detect($wc_url);
297
298
			if ( $source_url ) {
299
				$this->setSetting(self::SETTING_MERGE_SOURCE_URL, $source_url);
300
			}
301
		}
302
303
		if ( !$source_url ) {
304
			$error_msg = 'Unable to determine source url for merge.' . PHP_EOL;
305
			$error_msg .= 'Please specify it manually using "--source-url" option';
306
			throw new CommandException($error_msg);
307
		}
308
309
		return $source_url;
310
	}
311
312
	/**
313
	 * Prints information about merge source & target.
314
	 *
315
	 * @param string $source_url Merge source: url.
316
	 * @param string $wc_path    Merge target: working copy path.
317
	 *
318
	 * @return void
319
	 */
320
	protected function printSourceAndTarget($source_url, $wc_path)
321
	{
322
		$relative_source_url = $this->repositoryConnector->getRelativePath($source_url);
323
		$relative_target_url = $this->repositoryConnector->getRelativePath($wc_path);
324
325
		$this->io->writeln(' * Merge Source ... <info>' . $relative_source_url . '</info>');
326
		$this->io->writeln(' * Merge Target ... <info>' . $relative_target_url . '</info>');
327
	}
328
329
	/**
330
	 * Ensures, that there are some unmerged revisions.
331
	 *
332
	 * @param string $source_url Merge source: url.
333
	 * @param string $wc_path    Merge target: working copy path.
334
	 *
335
	 * @return array
336
	 */
337
	protected function getUnmergedRevisions($source_url, $wc_path)
338
	{
339
		// Avoid missing revision query progress bar overwriting following output.
340
		$revision_log = $this->getRevisionLog($source_url);
341
342
		$this->io->write(' * Upcoming Merge Status ... ');
343
		$unmerged_revisions = $this->calculateUnmergedRevisions($source_url, $wc_path);
344
345
		if ( $unmerged_revisions ) {
346
			$unmerged_bugs = $revision_log->getBugsFromRevisions($unmerged_revisions);
347
			$error_msg = '<error>%d revision(-s) or %d bug(-s) not merged</error>';
348
			$this->io->writeln(sprintf($error_msg, count($unmerged_revisions), count($unmerged_bugs)));
349
		}
350
		else {
351
			$this->io->writeln('<info>Up to date</info>');
352
		}
353
354
		return $unmerged_revisions;
355
	}
356
357
	/**
358
	 * Returns not merged revisions.
359
	 *
360
	 * @param string $source_url Merge source: url.
361
	 * @param string $wc_path    Merge target: working copy path.
362
	 *
363
	 * @return array
364
	 */
365
	protected function calculateUnmergedRevisions($source_url, $wc_path)
366
	{
367
		$command = $this->repositoryConnector->getCommand(
368
			'mergeinfo',
369
			'--show-revs eligible {' . $source_url . '} {' . $wc_path . '}'
370
		);
371
372
		$merge_info = $this->repositoryConnector->getProperty('svn:mergeinfo', $wc_path);
373
374
		$cache_invalidator = array(
375
			'source:' . $this->repositoryConnector->getLastRevision($source_url),
376
			'merged_hash:' . crc32($merge_info),
377
		);
378
		$command->setCacheInvalidator(implode(';', $cache_invalidator));
379
380
		$merge_info = $command->run();
381
		$merge_info = explode(PHP_EOL, $merge_info);
382
383
		foreach ( $merge_info as $index => $revision ) {
384
			$merge_info[$index] = ltrim($revision, 'r');
385
		}
386
387
		return array_filter($merge_info);
388
	}
389
390
	/**
391
	 * Parses information from "svn:mergeinfo" property.
392
	 *
393
	 * @param string $source_path Merge source: path in repository.
394
	 * @param string $wc_path     Merge target: working copy path.
395
	 *
396
	 * @return array
397
	 */
398
	protected function getMergedRevisions($source_path, $wc_path)
399
	{
400
		$merge_info = $this->repositoryConnector->getProperty('svn:mergeinfo', $wc_path);
401
		$merge_info = array_filter(explode("\n", $merge_info));
402
403
		foreach ( $merge_info as $merge_info_line ) {
404
			list($path, $revisions) = explode(':', $merge_info_line, 2);
405
406
			if ( $path == $source_path ) {
407
				return $this->_revisionListParser->expandRanges(explode(',', $revisions));
408
			}
409
		}
410
411
		return array();
412
	}
413
414
	/**
415
	 * Validates revisions to actually exist.
416
	 *
417
	 * @param array  $revisions      Revisions.
418
	 * @param string $repository_url Repository url.
419
	 *
420
	 * @return array
421
	 * @throws CommandException When revision doesn't exist.
422
	 */
423
	protected function getDirectRevisions(array $revisions, $repository_url)
424
	{
425
		$revision_log = $this->getRevisionLog($repository_url);
426
427
		try {
428
			$revisions = $this->_revisionListParser->expandRanges($revisions);
429
			$revision_log->getRevisionsData('summary', $revisions);
430
		}
431
		catch ( \InvalidArgumentException $e ) {
432
			throw new CommandException($e->getMessage());
433
		}
434
435
		return $revisions;
436
	}
437
438
	/**
439
	 * Performs merge.
440
	 *
441
	 * @param string $source_url Merge source: url.
442
	 * @param string $wc_path    Merge target: working copy path.
443
	 * @param array  $revisions  Revisions to merge.
444
	 *
445
	 * @return void
446
	 */
447
	protected function performMerge($source_url, $wc_path, array $revisions)
448
	{
449
		sort($revisions, SORT_NUMERIC);
450
451
		foreach ( $revisions as $revision ) {
452
			$command = $this->repositoryConnector->getCommand(
453
				'merge',
454
				'-c ' . $revision . ' {' . $source_url . '} {' . $wc_path . '}'
455
			);
456
457
			$merge_line = '--- Merging r' . $revision . " into '.':";
458
			$command->runLive(array(
459
				$wc_path => '.',
460
				$merge_line => '<fg=white;options=bold>' . $merge_line . '</>',
461
			));
462
463
			$this->_unmergedRevisions = array_diff($this->_unmergedRevisions, array($revision));
464
			$this->ensureWorkingCopyWithoutConflicts($source_url, $wc_path, $revision);
465
		}
466
	}
467
468
	/**
469
	 * Ensures, that there are no unresolved conflicts in working copy.
470
	 *
471
	 * @param string  $source_url                 Source url.
472
	 * @param string  $wc_path                    Working copy path.
473
	 * @param integer $largest_suggested_revision Largest revision, that is suggested in error message.
474
	 *
475
	 * @return void
476
	 * @throws CommandException When merge conflicts detected.
477
	 */
478
	protected function ensureWorkingCopyWithoutConflicts($source_url, $wc_path, $largest_suggested_revision = null)
479
	{
480
		$this->io->write(' * Previous Merge Status ... ');
481
482
		$conflicts = $this->repositoryConnector->getWorkingCopyConflicts($wc_path);
483
484
		if ( !$conflicts ) {
485
			$this->io->writeln('<info>Successful</info>');
486
487
			return;
488
		}
489
490
		$this->rememberConflicts($conflicts);
491
		$this->io->writeln('<error>' . count($conflicts) . ' conflict(-s)</error>');
492
493
		$table = new Table($this->io->getOutput());
494
495
		if ( $largest_suggested_revision ) {
496
			$table->setHeaders(array(
497
				'Path',
498
				'Associated Revisions (before ' . $largest_suggested_revision . ')',
499
			));
500
		}
501
		else {
502
			$table->setHeaders(array(
503
				'Path',
504
				'Associated Revisions',
505
			));
506
		}
507
508
		$revision_log = $this->getRevisionLog($source_url);
509
		$source_path = $this->repositoryConnector->getRelativePath($source_url) . '/';
510
511
		/** @var OutputHelper $output_helper */
512
		$output_helper = $this->getHelper('output');
513
514
		foreach ( $conflicts as $conflict_path ) {
515
			$path_revisions = $revision_log->find('paths', $source_path . $conflict_path);
516
			$path_revisions = array_intersect($this->_unmergedRevisions, $path_revisions);
517
518
			if ( $path_revisions && isset($largest_suggested_revision) ) {
519
				$path_revisions = $this->limitRevisions($path_revisions, $largest_suggested_revision);
520
			}
521
522
			$table->addRow(array(
523
				$conflict_path,
524
				$path_revisions ? $output_helper->formatArray($path_revisions, 4) : '-',
525
			));
526
		}
527
528
		$table->render();
529
530
		throw new CommandException('Working copy contains unresolved merge conflicts.');
531
	}
532
533
	/**
534
	 * Adds new conflicts to already remembered ones.
535
	 *
536
	 * @param array $conflicts Conflicts.
537
	 *
538
	 * @return void
539
	 */
540
	protected function rememberConflicts(array $conflicts)
541
	{
542
		$previous_conflicts = $this->getSetting(self::SETTING_MERGE_RECENT_CONFLICTS);
543
		$new_conflicts = array_unique(array_merge($previous_conflicts, $conflicts));
544
545
		$this->setSetting(self::SETTING_MERGE_RECENT_CONFLICTS, $new_conflicts);
546
	}
547
548
	/**
549
	 * Returns revisions not larger, then given one.
550
	 *
551
	 * @param array   $revisions    Revisions.
552
	 * @param integer $max_revision Maximal revision.
553
	 *
554
	 * @return array
555
	 */
556
	protected function limitRevisions(array $revisions, $max_revision)
557
	{
558
		$ret = array();
559
560
		foreach ( $revisions as $revision ) {
561
			if ( $revision < $max_revision ) {
562
				$ret[] = $revision;
563
			}
564
		}
565
566
		return $ret;
567
	}
568
569
	/**
570
	 * Returns list of config settings.
571
	 *
572
	 * @return AbstractConfigSetting[]
573
	 */
574
	public function getConfigSettings()
575
	{
576
		return array(
577
			new StringConfigSetting(self::SETTING_MERGE_SOURCE_URL, ''),
578
			new ArrayConfigSetting(self::SETTING_MERGE_RECENT_CONFLICTS, array()),
579
		);
580
	}
581
582
}
583