Passed
Push — master ( c6d56d...c89a06 )
by Alexander
11:25
created

MergeCommand::printRevisions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 10
c 1
b 0
f 0
dl 0
loc 12
ccs 0
cts 10
cp 0
rs 9.9332
cc 1
nc 1
nop 2
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\Exception\RepositoryCommandException;
20
use ConsoleHelpers\SVNBuddy\Helper\OutputHelper;
21
use ConsoleHelpers\SVNBuddy\MergeSourceDetector\AbstractMergeSourceDetector;
22
use ConsoleHelpers\SVNBuddy\Repository\Connector\UrlResolver;
23
use ConsoleHelpers\SVNBuddy\Repository\Parser\RevisionListParser;
24
use ConsoleHelpers\SVNBuddy\Repository\WorkingCopyConflictTracker;
25
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
26
use Symfony\Component\Console\Helper\Table;
27
use Symfony\Component\Console\Input\InputArgument;
28
use Symfony\Component\Console\Input\InputInterface;
29
use Symfony\Component\Console\Input\InputOption;
30
use Symfony\Component\Console\Output\OutputInterface;
31
32
class MergeCommand extends AbstractCommand implements IAggregatorAwareCommand, IConfigAwareCommand
33
{
34
35
	const SETTING_MERGE_SOURCE_URL = 'merge.source-url';
36
37
	const SETTING_MERGE_RECENT_CONFLICTS = 'merge.recent-conflicts';
38
39
	const SETTING_MERGE_AUTO_COMMIT = 'merge.auto-commit';
40
41
	const REVISION_ALL = 'all';
42
43
	/**
44
	 * Merge source detector.
45
	 *
46
	 * @var AbstractMergeSourceDetector
47
	 */
48
	private $_mergeSourceDetector;
49
50
	/**
51
	 * Revision list parser.
52
	 *
53
	 * @var RevisionListParser
54
	 */
55
	private $_revisionListParser;
56
57
	/**
58
	 * Usable revisions (either to be merged OR to be unmerged).
59
	 *
60
	 * @var array
61
	 */
62
	private $_usableRevisions = array();
63
64
	/**
65
	 * Url resolver.
66
	 *
67
	 * @var UrlResolver
68
	 */
69
	private $_urlResolver;
70
71
	/**
72
	 * Working copy conflict tracker.
73
	 *
74
	 * @var WorkingCopyConflictTracker
75
	 */
76
	private $_workingCopyConflictTracker;
77
78
	/**
79
	 * Prepare dependencies.
80
	 *
81
	 * @return void
82
	 */
83
	protected function prepareDependencies()
84
	{
85
		parent::prepareDependencies();
86
87
		$container = $this->getContainer();
88
89
		$this->_mergeSourceDetector = $container['merge_source_detector'];
90
		$this->_revisionListParser = $container['revision_list_parser'];
91
		$this->_urlResolver = $container['repository_url_resolver'];
92
		$this->_workingCopyConflictTracker = $container['working_copy_conflict_tracker'];
93
	}
94
95
	/**
96
	 * {@inheritdoc}
97
	 */
98
	protected function configure()
99
	{
100
		$this
101
			->setName('merge')
102
			->setDescription('Merge changes from another project or ref within same project into a working copy')
103
			->addArgument(
104
				'path',
105
				InputArgument::OPTIONAL,
106
				'Working copy path',
107
				'.'
108
			)
109
			->addOption(
110
				'source-url',
111
				null,
112
				InputOption::VALUE_REQUIRED,
113
				'Merge source url (absolute or relative) or ref name, e.g. <comment>branches/branch-name</comment>'
114
			)
115
			->addOption(
116
				'revisions',
117
				'r',
118
				InputOption::VALUE_REQUIRED,
119
				'List of revision(-s) and/or revision range(-s) to merge, e.g. <comment>53324</comment>, <comment>1224-4433</comment> or <comment>all</comment>'
120
			)
121
			->addOption(
122
				'exclude-revisions',
123
				null,
124
				InputOption::VALUE_REQUIRED,
125
				'List of revision(-s) and/or revision range(-s) not to merge, e.g. <comment>53324</comment>, <comment>1224-4433</comment>'
126
			)
127
			->addOption(
128
				'bugs',
129
				'b',
130
				InputOption::VALUE_REQUIRED,
131
				'List of bug(-s) to merge, e.g. <comment>JRA-1234</comment>, <comment>43644</comment>'
132
			)
133
			->addOption(
134
				'exclude-bugs',
135
				null,
136
				InputOption::VALUE_REQUIRED,
137
				'List of bug(-s) not to merge, e.g. <comment>JRA-1234</comment>, <comment>43644</comment>'
138
			)
139
			->addOption(
140
				'merges',
141
				null,
142
				InputOption::VALUE_NONE,
143
				'Show merge revisions only'
144
			)
145
			->addOption(
146
				'no-merges',
147
				null,
148
				InputOption::VALUE_NONE,
149
				'Hide merge revisions'
150
			)
151
			->addOption(
152
				'with-full-message',
153
				'f',
154
				InputOption::VALUE_NONE,
155
				'Shows non-truncated commit messages'
156
			)
157
			->addOption(
158
				'with-details',
159
				'd',
160
				InputOption::VALUE_NONE,
161
				'Shows detailed revision information, e.g. paths affected'
162
			)
163
			->addOption(
164
				'with-summary',
165
				's',
166
				InputOption::VALUE_NONE,
167
				'Shows number of added/changed/removed paths in the revision'
168
			)
169
			->addOption(
170
				'update-revision',
171
				null,
172
				InputOption::VALUE_REQUIRED,
173
				'Update working copy to given revision before performing a merge'
174
			)
175
			->addOption(
176
				'auto-commit',
177
				null,
178
				InputOption::VALUE_REQUIRED,
179
				'Automatically perform commit on successful merge, e.g. <comment>yes</comment> or <comment>no</comment>'
180
			)
181
			->addOption(
182
				'record-only',
183
				null,
184
				InputOption::VALUE_NONE,
185
				'Mark revisions as merged without actually merging them'
186
			)
187
			->addOption(
188
				'reverse',
189
				null,
190
				InputOption::VALUE_NONE,
191
				'Rollback previously merged revisions'
192
			)
193
			->addOption(
194
				'aggregate',
195
				'a',
196
				InputOption::VALUE_NONE,
197
				'Aggregate displayed revisions by bugs'
198
			)
199
			->addOption(
200
				'preview',
201
				'p',
202
				InputOption::VALUE_NONE,
203
				'Preview revisions to be merged'
204
			);
205
206
		parent::configure();
207
	}
208
209
	/**
210
	 * Return possible values for the named option
211
	 *
212
	 * @param string            $optionName Option name.
213
	 * @param CompletionContext $context    Completion context.
214
	 *
215
	 * @return array
216
	 */
217
	public function completeOptionValues($optionName, CompletionContext $context)
218
	{
219
		$ret = parent::completeOptionValues($optionName, $context);
220
221
		if ( $optionName === 'revisions' ) {
222
			return array('all');
223
		}
224
225
		if ( $optionName === 'source-url' ) {
226
			return $this->getAllRefs();
227
		}
228
229
		if ( $optionName === 'auto-commit' ) {
230
			return array('yes', 'no');
231
		}
232
233
		return $ret;
234
	}
235
236
	/**
237
	 * {@inheritdoc}
238
	 *
239
	 * @throws CommandException When everything is merged.
240
	 * @throws CommandException When manually specified revisions are already merged.
241
	 * @throws CommandException When bugs from "--bugs" option are not found.
242
	 */
243
	protected function execute(InputInterface $input, OutputInterface $output)
244
	{
245
		$bugs = $this->getList($this->io->getOption('bugs'));
0 ignored issues
show
Bug introduced by
It seems like $this->io->getOption('bugs') can also be of type string[]; however, parameter $string of ConsoleHelpers\SVNBuddy\...tractCommand::getList() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

245
		$bugs = $this->getList(/** @scrutinizer ignore-type */ $this->io->getOption('bugs'));
Loading history...
246
		$revisions = $this->getList($this->io->getOption('revisions'));
247
248
		$wc_path = $this->getWorkingCopyPath();
249
250
		$this->ensureLatestWorkingCopy($wc_path);
251
252
		$source_url = $this->getSourceUrl($wc_path);
253
		$this->printSourceAndTarget($source_url, $wc_path);
254
		$this->_usableRevisions = $this->getUsableRevisions($source_url, $wc_path);
255
256
		if ( ($bugs || $revisions) && !$this->_usableRevisions ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $revisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $bugs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->_usableRevisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
257
			throw new CommandException(\sprintf(
258
				'Nothing to %s.',
259
				$this->isReverseMerge() ? 'reverse-merge' : 'merge'
260
			));
261
		}
262
263
		$this->ensureWorkingCopyWithoutConflicts($source_url, $wc_path);
264
265
		if ( $this->shouldUseAll($revisions) ) {
266
			$revisions = $this->filterMergeableRevisions($this->_usableRevisions, $source_url);
267
		}
268
		else {
269
			if ( $revisions ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $revisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
270
				$revisions = $this->getDirectRevisions($revisions, $source_url);
271
			}
272
273
			if ( $bugs ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $bugs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
274
				$revisions_from_bugs = $this->getRevisionLog($source_url)->find('bugs', $bugs);
275
276
				if ( !$revisions_from_bugs ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $revisions_from_bugs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
277
					throw new CommandException('Specified bugs aren\'t mentioned in any of revisions');
278
				}
279
280
				$revisions = array_merge($revisions, $revisions_from_bugs);
281
			}
282
283
			$revisions = $this->filterMergeableRevisions($revisions, $source_url);
284
285
			if ( $revisions ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $revisions of type integer[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
286
				$revisions = array_intersect($revisions, $this->_usableRevisions);
287
288
				if ( !$revisions ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $revisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
289
					throw new CommandException(\sprintf(
290
						'Requested revisions are %s',
291
						$this->isReverseMerge() ? 'not yet merged' : 'already merged'
292
					));
293
				}
294
			}
295
		}
296
297
		if ( $revisions ) {
298
			if ( $this->io->getOption('preview') ) {
299
				// Display mergeable revisions according to user-specified filters.
300
				$this->printRevisions($source_url, $revisions);
301
			}
302
			else {
303
				// Perform merge using user-specified filters.
304
				$this->performMerge($source_url, $wc_path, $revisions);
305
			}
306
		}
307
		elseif ( $this->_usableRevisions ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->_usableRevisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
308
			// Display all mergeable revisions, because user haven't specified any revisions for merging.
309
			$this->printRevisions($source_url, $this->_usableRevisions);
310
		}
311
	}
312
313
	/**
314
	 * Filters mergeable revision list.
315
	 *
316
	 * @param array  $revisions  Revisions.
317
	 * @param string $source_url Source URL.
318
	 *
319
	 * @return integer[]
320
	 * @throws CommandException When bugs from "--exclude-bugs" option are not found.
321
	 */
322
	protected function filterMergeableRevisions(array $revisions, $source_url)
323
	{
324
		$exclude_bugs = $this->getList($this->io->getOption('exclude-bugs'));
0 ignored issues
show
Bug introduced by
It seems like $this->io->getOption('exclude-bugs') can also be of type string[]; however, parameter $string of ConsoleHelpers\SVNBuddy\...tractCommand::getList() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

324
		$exclude_bugs = $this->getList(/** @scrutinizer ignore-type */ $this->io->getOption('exclude-bugs'));
Loading history...
325
		$exclude_revisions = $this->getList($this->io->getOption('exclude-revisions'));
326
327
		if ( $exclude_revisions ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $exclude_revisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
328
			$revisions = array_diff(
329
				$revisions,
330
				$this->getDirectRevisions($exclude_revisions, $source_url)
331
			);
332
		}
333
334
		if ( $exclude_bugs ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $exclude_bugs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
335
			$exclude_revisions_from_bugs = $this->getRevisionLog($source_url)->find('bugs', $exclude_bugs);
336
337
			if ( !$exclude_revisions_from_bugs ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $exclude_revisions_from_bugs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
338
				throw new CommandException('Specified exclude-bugs aren\'t mentioned in any of revisions');
339
			}
340
341
			$revisions = array_diff($revisions, $exclude_revisions_from_bugs);
342
		}
343
344
		if ( $this->io->getOption('merges') ) {
345
			$revisions = array_intersect($revisions, $this->getRevisionLog($source_url)->find('merges', 'all_merges'));
346
		}
347
		elseif ( $this->io->getOption('no-merges') ) {
348
			$revisions = array_diff($revisions, $this->getRevisionLog($source_url)->find('merges', 'all_merges'));
349
		}
350
351
		return $revisions;
352
	}
353
354
	/**
355
	 * Determines if all usable revisions should be processed.
356
	 *
357
	 * @param array $revisions Revisions.
358
	 *
359
	 * @return boolean
360
	 */
361
	protected function shouldUseAll(array $revisions)
362
	{
363
		return $revisions === array(self::REVISION_ALL);
364
	}
365
366
	/**
367
	 * Ensures, that working copy is up to date.
368
	 *
369
	 * @param string $wc_path Working copy path.
370
	 *
371
	 * @return void
372
	 */
373
	protected function ensureLatestWorkingCopy($wc_path)
374
	{
375
		$this->io->write(' * Working Copy Status ... ');
376
		$update_revision = $this->io->getOption('update-revision');
377
378
		if ( $this->repositoryConnector->getWorkingCopyMissing($wc_path) ) {
379
			$this->io->writeln('<error>Locally deleted files found</error>');
380
			$this->updateWorkingCopy($wc_path, $update_revision);
381
382
			return;
383
		}
384
385
		$working_copy_revisions = $this->repositoryConnector->getWorkingCopyRevisions($wc_path);
386
387
		if ( count($working_copy_revisions) > 1 ) {
388
			$this->io->writeln(
389
				'<error>Mixed revisions: ' . implode(', ', $working_copy_revisions) . '</error>'
390
			);
391
			$this->updateWorkingCopy($wc_path, $update_revision);
392
393
			return;
394
		}
395
396
		$update_revision = $this->getWorkingCopyUpdateRevision($wc_path);
397
398
		if ( isset($update_revision) ) {
399
			$this->io->writeln('<error>Not at ' . $update_revision . ' revision</error>');
400
			$this->updateWorkingCopy($wc_path, $update_revision);
401
402
			return;
403
		}
404
405
		$this->io->writeln('<info>Up to date</info>');
406
	}
407
408
	/**
409
	 * Returns revision, that working copy needs to be updated to.
410
	 *
411
	 * @param string $wc_path Working copy path.
412
	 *
413
	 * @return integer|null
414
	 */
415
	protected function getWorkingCopyUpdateRevision($wc_path)
416
	{
417
		$update_revision = $this->io->getOption('update-revision');
418
		$actual_revision = $this->repositoryConnector->getLastRevision($wc_path);
419
420
		if ( isset($update_revision) ) {
421
			if ( is_numeric($update_revision) ) {
422
				return (int)$update_revision === (int)$actual_revision ? null : $update_revision;
0 ignored issues
show
Bug Best Practice introduced by
The expression return (int)$update_revi...null : $update_revision also could return the type string which is incompatible with the documented return type integer|null.
Loading history...
423
			}
424
425
			return $update_revision;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $update_revision returns the type boolean|string|string[] which is incompatible with the documented return type integer|null.
Loading history...
426
		}
427
428
		$repository_revision = $this->repositoryConnector->getLastRevision(
429
			$this->repositoryConnector->getWorkingCopyUrl($wc_path)
430
		);
431
432
		return $repository_revision > $actual_revision ? $repository_revision : null;
433
	}
434
435
	/**
436
	 * Updates working copy.
437
	 *
438
	 * @param string     $wc_path  Working copy path.
439
	 * @param mixed|null $revision Revision.
440
	 *
441
	 * @return void
442
	 */
443
	protected function updateWorkingCopy($wc_path, $revision = null)
444
	{
445
		$arguments = array('path' => $wc_path, '--ignore-externals' => true);
446
447
		if ( isset($revision) ) {
448
			$arguments['--revision'] = $revision;
449
		}
450
451
		$this->runOtherCommand('update', $arguments);
452
	}
453
454
	/**
455
	 * Returns source url for merge.
456
	 *
457
	 * @param string $wc_path Working copy path.
458
	 *
459
	 * @return string
460
	 * @throws CommandException When source path is invalid.
461
	 */
462
	protected function getSourceUrl($wc_path)
463
	{
464
		$source_url = $this->io->getOption('source-url');
465
466
		if ( $source_url === null ) {
467
			$source_url = $this->getSetting(self::SETTING_MERGE_SOURCE_URL);
468
		}
469
		elseif ( !$this->repositoryConnector->isUrl($source_url) ) {
0 ignored issues
show
Bug introduced by
It seems like $source_url can also be of type string[]; however, parameter $path of ConsoleHelpers\SVNBuddy\...ctor\Connector::isUrl() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

469
		elseif ( !$this->repositoryConnector->isUrl(/** @scrutinizer ignore-type */ $source_url) ) {
Loading history...
470
			$wc_url = $this->repositoryConnector->getWorkingCopyUrl($wc_path);
471
			$source_url = $this->_urlResolver->resolve($wc_url, $source_url);
0 ignored issues
show
Bug introduced by
It seems like $source_url can also be of type string[]; however, parameter $url_to_resolve of ConsoleHelpers\SVNBuddy\...\UrlResolver::resolve() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

471
			$source_url = $this->_urlResolver->resolve($wc_url, /** @scrutinizer ignore-type */ $source_url);
Loading history...
472
		}
473
474
		if ( !$source_url ) {
475
			$wc_url = $this->repositoryConnector->getWorkingCopyUrl($wc_path);
476
			$source_url = $this->_mergeSourceDetector->detect($wc_url);
477
478
			if ( $source_url ) {
479
				$this->setSetting(self::SETTING_MERGE_SOURCE_URL, $source_url);
480
			}
481
		}
482
483
		if ( !$source_url ) {
484
			$wc_url = $this->repositoryConnector->getWorkingCopyUrl($wc_path);
485
			$error_msg = 'Unable to guess "--source-url" option value. Please specify it manually.' . PHP_EOL;
486
			$error_msg .= 'Working Copy URL: ' . $wc_url . '.';
487
			throw new CommandException($error_msg);
488
		}
489
490
		return $source_url;
491
	}
492
493
	/**
494
	 * Prints information about merge source & target.
495
	 *
496
	 * @param string $source_url Merge source: url.
497
	 * @param string $wc_path    Merge target: working copy path.
498
	 *
499
	 * @return void
500
	 */
501
	protected function printSourceAndTarget($source_url, $wc_path)
502
	{
503
		$relative_source_url = $this->repositoryConnector->getRelativePath($source_url);
504
		$relative_target_url = $this->repositoryConnector->getRelativePath($wc_path);
505
506
		$this->io->writeln(' * Merge Source ... <info>' . $relative_source_url . '</info>');
507
		$this->io->writeln(' * Merge Target ... <info>' . $relative_target_url . '</info>');
508
	}
509
510
	/**
511
	 * Ensures, that there are some usable revisions.
512
	 *
513
	 * @param string $source_url Merge source: url.
514
	 * @param string $wc_path    Merge target: working copy path.
515
	 *
516
	 * @return array
517
	 */
518
	protected function getUsableRevisions($source_url, $wc_path)
519
	{
520
		// Avoid missing revision query progress bar overwriting following output.
521
		$revision_log = $this->getRevisionLog($source_url);
522
523
		$this->io->write(sprintf(
524
			' * Upcoming %s Status (no filters) ... ',
525
			$this->isReverseMerge() ? 'Reverse-merge' : 'Merge'
526
		));
527
		$usable_revisions = $this->calculateUsableRevisions($source_url, $wc_path);
528
529
		if ( $usable_revisions ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $usable_revisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
530
			$usable_bugs = $revision_log->getBugsFromRevisions($usable_revisions);
531
			$error_msg = '<error>%d revision(-s) or %d bug(-s) %s</error>';
532
			$this->io->writeln(sprintf(
533
				$error_msg,
534
				count($usable_revisions),
535
				count($usable_bugs),
536
				$this->isReverseMerge() ? 'merged' : 'not merged'
537
			));
538
		}
539
		else {
540
			$this->io->writeln('<info>Up to date</info>');
541
		}
542
543
		return $usable_revisions;
544
	}
545
546
	/**
547
	 * Returns usable revisions.
548
	 *
549
	 * @param string $source_url Merge source: url.
550
	 * @param string $wc_path    Merge target: working copy path.
551
	 *
552
	 * @return array
553
	 */
554
	protected function calculateUsableRevisions($source_url, $wc_path)
555
	{
556
		$command = $this->repositoryConnector->getCommand(
557
			'mergeinfo',
558
			sprintf(
559
				'--show-revs %s {%s} {%s}',
560
				$this->isReverseMerge() ? 'merged' : 'eligible',
561
				$source_url,
562
				$wc_path
563
			)
564
		);
565
566
		$merge_info = $this->repositoryConnector->getProperty('svn:mergeinfo', $wc_path);
567
568
		$cache_invalidator = array(
569
			'source:' . $this->repositoryConnector->getLastRevision($source_url),
570
			'merged_hash:' . crc32($merge_info),
571
		);
572
		$command->setCacheInvalidator(implode(';', $cache_invalidator));
573
574
		$merge_info = $command->run();
575
		$merge_info = explode(PHP_EOL, $merge_info);
576
577
		foreach ( $merge_info as $index => $revision ) {
578
			$merge_info[$index] = ltrim($revision, 'r');
579
		}
580
581
		return array_filter($merge_info);
582
	}
583
584
	/**
585
	 * Parses information from "svn:mergeinfo" property.
586
	 *
587
	 * @param string $source_path Merge source: path in repository.
588
	 * @param string $wc_path     Merge target: working copy path.
589
	 *
590
	 * @return array
591
	 */
592
	protected function getMergedRevisions($source_path, $wc_path)
593
	{
594
		$merge_info = $this->repositoryConnector->getProperty('svn:mergeinfo', $wc_path);
595
		$merge_info = array_filter(explode("\n", $merge_info));
596
597
		foreach ( $merge_info as $merge_info_line ) {
598
			list($path, $revisions) = explode(':', $merge_info_line, 2);
599
600
			if ( $path === $source_path ) {
601
				return $this->_revisionListParser->expandRanges(explode(',', $revisions));
602
			}
603
		}
604
605
		return array();
606
	}
607
608
	/**
609
	 * Validates revisions to actually exist.
610
	 *
611
	 * @param array  $revisions      Revisions.
612
	 * @param string $repository_url Repository url.
613
	 *
614
	 * @return array
615
	 * @throws CommandException When revision doesn't exist.
616
	 */
617
	protected function getDirectRevisions(array $revisions, $repository_url)
618
	{
619
		$revision_log = $this->getRevisionLog($repository_url);
620
621
		try {
622
			$revisions = $this->_revisionListParser->expandRanges($revisions);
623
			$revision_log->getRevisionsData('summary', $revisions);
624
		}
625
		catch ( \InvalidArgumentException $e ) {
626
			throw new CommandException($e->getMessage());
627
		}
628
629
		return $revisions;
630
	}
631
632
	/**
633
	 * Performs merge.
634
	 *
635
	 * @param string $source_url Merge source: url.
636
	 * @param string $wc_path    Merge target: working copy path.
637
	 * @param array  $revisions  Revisions to merge.
638
	 *
639
	 * @return void
640
	 */
641
	protected function performMerge($source_url, $wc_path, array $revisions)
642
	{
643
		if ( $this->isReverseMerge() ) {
644
			rsort($revisions, SORT_NUMERIC);
645
		}
646
		else {
647
			sort($revisions, SORT_NUMERIC);
648
		}
649
650
		$revision_count = count($revisions);
651
652
		$used_revision_count = 0;
653
		$used_revisions = $this->repositoryConnector->getMergedRevisionChanges($wc_path, !$this->isReverseMerge());
654
655
		if ( $used_revisions ) {
656
			$used_revisions = call_user_func_array('array_merge', $used_revisions);
657
			$used_revision_count = count($used_revisions);
658
			$revision_count += $used_revision_count;
659
		}
660
661
		$param_string_beginning = '-c ';
662
		$param_string_ending = '{' . $source_url . '} {' . $wc_path . '}';
663
664
		if ( $this->isReverseMerge() ) {
665
			$param_string_beginning .= '-';
666
		}
667
668
		if ( $this->io->getOption('record-only') ) {
669
			$param_string_ending = '--record-only ' . $param_string_ending;
670
		}
671
672
		$revision_log = $this->getRevisionLog($source_url);
673
		$revisions_data = $revision_log->getRevisionsData('summary', $revisions);
674
675
		$revision_title_mask = $this->getRevisionTitle($source_url);
676
677
		foreach ( $revisions as $index => $revision ) {
678
			$command = $this->repositoryConnector->getCommand(
679
				'merge',
680
				$param_string_beginning . $revision . ' ' . $param_string_ending
681
			);
682
683
			$progress_bar = $this->createMergeProgressBar($used_revision_count + $index + 1, $revision_count);
684
685
			// 1. Add revision link with a progress bar.
686
			$merge_heading = PHP_EOL . '<fg=white;options=bold>';
687
			$merge_heading .= '--- $1 ' . \str_replace('{revision}', $revision, $revision_title_mask);
688
			$merge_heading .= " into '$2' " . $progress_bar . ':</>';
689
690
			// 2. Add a commit message.
691
			$commit_message = trim($revisions_data[$revision]['msg']);
692
			$commit_message = wordwrap($commit_message, 68); // FIXME: Not UTF-8 safe solution.
693
			$merge_heading .= PHP_EOL . $commit_message;
694
			$merge_heading .= PHP_EOL;
695
			$merge_heading .= PHP_EOL . '<fg=white;options=bold>Changed Paths:</>';
696
697
			$command->runLive(array(
698
				$wc_path => '.',
699
				'/--- (Merging|Reverse-merging) r' . $revision . " into '([^']*)':/" => $merge_heading,
700
			));
701
702
			$this->_usableRevisions = array_diff($this->_usableRevisions, array($revision));
703
			$this->ensureWorkingCopyWithoutConflicts($source_url, $wc_path, $revision);
704
		}
705
706
		$this->performCommit();
707
	}
708
709
	/**
710
	 * Returns revision title.
711
	 *
712
	 * @param string $source_url Merge source: url.
713
	 *
714
	 * @return string
715
	 */
716
	protected function getRevisionTitle($source_url)
717
	{
718
		$link_style = 'fg=white;options=bold,underscore';
719
720
		try {
721
			$arcanist_config = \json_decode(
722
				$this->repositoryConnector->getFileContent($source_url . '/.arcconfig', 'HEAD'),
0 ignored issues
show
Bug introduced by
'HEAD' of type string is incompatible with the type integer expected by parameter $revision of ConsoleHelpers\SVNBuddy\...ector::getFileContent(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

722
				$this->repositoryConnector->getFileContent($source_url . '/.arcconfig', /** @scrutinizer ignore-type */ 'HEAD'),
Loading history...
723
				true
724
			);
725
		}
726
		catch ( RepositoryCommandException $e ) {
727
			// Phabricator integration is not configured.
728
			return '<' . $link_style . '>{revision}</> revision';
729
		}
730
731
		// Phabricator integration is not configured correctly.
732
		if ( !\is_array($arcanist_config)
733
			|| !isset($arcanist_config['repository.callsign'], $arcanist_config['phabricator.uri'])
734
		) {
735
			return '<' . $link_style . '>{revision}</> revision';
736
		}
737
738
		// Phabricator integration is configured properly.
739
		$revision_title = $arcanist_config['phabricator.uri'];
740
		$revision_title .= 'r' . $arcanist_config['repository.callsign'] . '{revision}';
741
742
		return '<' . $link_style . '>' . $revision_title . '</>';
743
	}
744
745
	/**
746
	 * Create merge progress bar.
747
	 *
748
	 * @param integer $current Current.
749
	 * @param integer $total   Total.
750
	 *
751
	 * @return string
752
	 */
753
	protected function createMergeProgressBar($current, $total)
754
	{
755
		$total_length = 28;
756
		$percent_used = floor(($current / $total) * 100);
757
		$length_used = floor(($total_length * $percent_used) / 100);
758
		$length_free = $total_length - $length_used;
759
760
		$ret = $length_used > 0 ? str_repeat('=', $length_used - 1) : '';
0 ignored issues
show
Bug introduced by
$length_used - 1 of type double is incompatible with the type integer expected by parameter $times of str_repeat(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

760
		$ret = $length_used > 0 ? str_repeat('=', /** @scrutinizer ignore-type */ $length_used - 1) : '';
Loading history...
761
		$ret .= '>' . str_repeat('-', $length_free);
762
763
		return '[' . $ret . '] ' . $percent_used . '% (' . $current . ' of ' . $total . ')';
764
	}
765
766
	/**
767
	 * Ensures, that there are no unresolved conflicts in working copy.
768
	 *
769
	 * @param string  $source_url                 Source url.
770
	 * @param string  $wc_path                    Working copy path.
771
	 * @param integer $largest_suggested_revision Largest revision, that is suggested in error message.
772
	 *
773
	 * @return void
774
	 * @throws CommandException When merge conflicts detected.
775
	 */
776
	protected function ensureWorkingCopyWithoutConflicts($source_url, $wc_path, $largest_suggested_revision = null)
777
	{
778
		$this->io->write(' * Previous Merge Status ... ');
779
780
		$conflicts = $this->_workingCopyConflictTracker->getNewConflicts($wc_path);
781
782
		if ( !$conflicts ) {
783
			$this->io->writeln('<info>Successful</info>');
784
785
			return;
786
		}
787
788
		$this->_workingCopyConflictTracker->add($wc_path);
789
		$this->io->writeln('<error>' . count($conflicts) . ' conflict(-s)</error>');
790
791
		$table = new Table($this->io->getOutput());
792
793
		if ( $largest_suggested_revision ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $largest_suggested_revision of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
794
			$table->setHeaders(array(
795
				'Path',
796
				'Associated Revisions (before ' . $largest_suggested_revision . ')',
797
			));
798
		}
799
		else {
800
			$table->setHeaders(array(
801
				'Path',
802
				'Associated Revisions',
803
			));
804
		}
805
806
		$revision_log = $this->getRevisionLog($source_url);
807
		$source_path = $this->repositoryConnector->getRelativePath($source_url) . '/';
808
809
		/** @var OutputHelper $output_helper */
810
		$output_helper = $this->getHelper('output');
811
812
		foreach ( $conflicts as $conflict_path ) {
813
			$path_revisions = $revision_log->find('paths', $source_path . $conflict_path);
814
			$path_revisions = array_intersect($this->_usableRevisions, $path_revisions);
815
816
			if ( $path_revisions && isset($largest_suggested_revision) ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $path_revisions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
817
				$path_revisions = $this->limitRevisions($path_revisions, $largest_suggested_revision);
818
			}
819
820
			$table->addRow(array(
821
				$conflict_path,
822
				$path_revisions ? $output_helper->formatArray($path_revisions, 4) : '-',
823
			));
824
		}
825
826
		$table->render();
827
828
		throw new CommandException('Working copy contains unresolved merge conflicts.');
829
	}
830
831
	/**
832
	 * Returns revisions not larger, then given one.
833
	 *
834
	 * @param array   $revisions    Revisions.
835
	 * @param integer $max_revision Maximal revision.
836
	 *
837
	 * @return array
838
	 */
839
	protected function limitRevisions(array $revisions, $max_revision)
840
	{
841
		$ret = array();
842
843
		foreach ( $revisions as $revision ) {
844
			if ( $revision < $max_revision ) {
845
				$ret[] = $revision;
846
			}
847
		}
848
849
		return $ret;
850
	}
851
852
	/**
853
	 * Performs commit unless user doesn't want it.
854
	 *
855
	 * @return void
856
	 */
857
	protected function performCommit()
858
	{
859
		$auto_commit = $this->io->getOption('auto-commit');
860
861
		if ( $auto_commit !== null ) {
862
			$auto_commit = $auto_commit === 'yes';
863
		}
864
		else {
865
			$auto_commit = (boolean)$this->getSetting(self::SETTING_MERGE_AUTO_COMMIT);
866
		}
867
868
		if ( $auto_commit ) {
869
			$this->io->writeln(array('', 'Commencing automatic commit after merge ...'));
870
			$this->runOtherCommand('commit');
871
		}
872
	}
873
874
	/**
875
	 * Prints revisions.
876
	 *
877
	 * @param string $source_url Merge source: url.
878
	 * @param array  $revisions  Revisions.
879
	 *
880
	 * @return void
881
	 */
882
	protected function printRevisions($source_url, array $revisions)
883
	{
884
		$this->runOtherCommand('log', array(
885
			'path' => $source_url,
886
			'--revisions' => implode(',', $revisions),
887
			'--merges' => $this->io->getOption('merges'),
888
			'--no-merges' => $this->io->getOption('no-merges'),
889
			'--with-full-message' => $this->io->getOption('with-full-message'),
890
			'--with-details' => $this->io->getOption('with-details'),
891
			'--with-summary' => $this->io->getOption('with-summary'),
892
			'--aggregate' => $this->io->getOption('aggregate'),
893
			'--with-merge-oracle' => true,
894
		));
895
	}
896
897
	/**
898
	 * Returns list of config settings.
899
	 *
900
	 * @return AbstractConfigSetting[]
901
	 */
902
	public function getConfigSettings()
903
	{
904
		return array(
905
			new StringConfigSetting(self::SETTING_MERGE_SOURCE_URL, ''),
906
			new ArrayConfigSetting(self::SETTING_MERGE_RECENT_CONFLICTS, array()),
907
			new ChoiceConfigSetting(
908
				self::SETTING_MERGE_AUTO_COMMIT,
909
				array(1 => 'Yes', 0 => 'No'),
910
				1
911
			),
912
		);
913
	}
914
915
	/**
916
	 * Returns option names, that makes sense to use in aggregation mode.
917
	 *
918
	 * @return array
919
	 */
920
	public function getAggregatedOptions()
921
	{
922
		return array('with-full-message', 'with-details', 'with-summary');
923
	}
924
925
	/**
926
	 * Determines if merge should be done in opposite direction (unmerge).
927
	 *
928
	 * @return boolean
929
	 */
930
	protected function isReverseMerge()
931
	{
932
		return $this->io->getOption('reverse');
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->io->getOption('reverse') also could return the type string|string[] which is incompatible with the documented return type boolean.
Loading history...
933
	}
934
935
}
936