Completed
Push — master ( db5833...77dde3 )
by Alexander
03:19
created

MergeCommand::updateWorkingCopy()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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