• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

eliashaeussler / version-bumper / 26179712348

20 May 2026 05:44PM UTC coverage: 78.161% (-10.5%) from 88.618%
26179712348

Pull #144

github

eliashaeussler
[FEATURE] Introduce `next-version` command
Pull Request #144: [FEATURE] Introduce `next-version` command

7 of 176 new or added lines in 5 files covered. (3.98%)

12 existing lines in 2 files now uncovered.

1156 of 1479 relevant lines covered (78.16%)

4.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.7
/src/Command/BumpVersionCommand.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the Composer package "eliashaeussler/version-bumper".
7
 *
8
 * Copyright (C) 2024-2026 Elias Häußler <elias@haeussler.dev>
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, either version 3 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace EliasHaeussler\VersionBumper\Command;
25

26
use Composer\Command;
27
use Composer\Composer;
28
use CuyZ\Valinor;
29
use EliasHaeussler\TaskRunner;
30
use EliasHaeussler\VersionBumper\Config;
31
use EliasHaeussler\VersionBumper\Enum;
32
use EliasHaeussler\VersionBumper\Error;
33
use EliasHaeussler\VersionBumper\Exception;
34
use EliasHaeussler\VersionBumper\Result;
35
use EliasHaeussler\VersionBumper\Version;
36
use GitElephant\Command\Caller;
37
use Symfony\Component\Console;
38
use Symfony\Component\Filesystem;
39
use Throwable;
40

41
use function array_filter;
42
use function array_map;
43
use function count;
44
use function dirname;
45
use function getcwd;
46
use function implode;
47
use function is_string;
48
use function method_exists;
49
use function reset;
50
use function sprintf;
51
use function trim;
52
use function usort;
53

54
/**
55
 * BumpVersionCommand.
56
 *
57
 * @author Elias Häußler <elias@haeussler.dev>
58
 * @license GPL-3.0-or-later
59
 */
60
final class BumpVersionCommand extends Command\BaseCommand
61
{
62
    private readonly Version\VersionBumper $bumper;
63
    private readonly Config\ConfigReader $configReader;
64
    private readonly Version\VersionRangeDetector $versionRangeDetector;
65
    private readonly Version\VersionReleaser $releaser;
66
    private readonly Error\DeprecationHandler $deprecationHandler;
67
    private Console\Style\SymfonyStyle $io;
68
    private TaskRunner\TaskRunner $taskRunner;
69

70
    public function __construct(
23✔
71
        ?Composer $composer = null,
72
        ?Caller\CallerInterface $caller = null,
73
    ) {
74
        if (null !== $composer) {
23✔
75
            $this->setComposer($composer);
1✔
76
        }
77

78
        parent::__construct('bump-version');
23✔
79

80
        $this->bumper = new Version\VersionBumper();
23✔
81
        $this->configReader = new Config\ConfigReader();
23✔
82
        $this->versionRangeDetector = new Version\VersionRangeDetector($caller);
23✔
83
        $this->releaser = new Version\VersionReleaser($caller);
23✔
84
        $this->deprecationHandler = Error\DeprecationHandler::new();
23✔
85
    }
86

87
    protected function configure(): void
23✔
88
    {
89
        $this->setAliases(['bv']);
23✔
90
        $this->setDescription('Bump package version in specific files during release preparations');
23✔
91

92
        $this->addArgument(
23✔
93
            'range',
23✔
94
            Console\Input\InputArgument::OPTIONAL,
23✔
95
            sprintf(
23✔
96
                'Version range (one of "%s") or explicit version to bump in configured files',
23✔
97
                implode('", "', Enum\VersionRange::all()),
23✔
98
            ),
23✔
99
        );
23✔
100

101
        $this->addOption(
23✔
102
            'config',
23✔
103
            'c',
23✔
104
            Console\Input\InputOption::VALUE_REQUIRED,
23✔
105
            'Path to configuration file (JSON, YAML or PHP) with files in which to bump new versions',
23✔
106
            $this->readConfigFileFromRootPackage(),
23✔
107
        );
23✔
108
        $this->addOption(
23✔
109
            'release',
23✔
110
            'r',
23✔
111
            Console\Input\InputOption::VALUE_NONE,
23✔
112
            'Create a new Git tag after versions are bumped',
23✔
113
        );
23✔
114
        $this->addOption(
23✔
115
            'dry-run',
23✔
116
            null,
23✔
117
            Console\Input\InputOption::VALUE_NONE,
23✔
118
            'Do not perform any write operations, just calculate version bumps',
23✔
119
        );
23✔
120
        $this->addOption(
23✔
121
            'strict',
23✔
122
            null,
23✔
123
            Console\Input\InputOption::VALUE_NONE,
23✔
124
            'Fail if any unmatched file pattern is reported',
23✔
125
        );
23✔
126
    }
127

128
    protected function initialize(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): void
23✔
129
    {
130
        $this->io = new Console\Style\SymfonyStyle($input, $output);
23✔
131
        $this->taskRunner = new TaskRunner\TaskRunner($this->io);
23✔
132
    }
133

134
    protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
23✔
135
    {
136
        $rootPath = (string) getcwd();
23✔
137
        $rangeOrVersion = $input->getArgument('range');
23✔
138
        $configFile = $input->getOption('config') ?? $this->configReader->detectFile($rootPath);
23✔
139
        $release = $input->getOption('release');
23✔
140
        $dryRun = $input->getOption('dry-run');
23✔
141
        $strict = $input->getOption('strict');
23✔
142

143
        if (null === $configFile) {
23✔
144
            $this->io->error('Please provide a config file path using the --config option.');
1✔
145

146
            return self::INVALID;
1✔
147
        }
148

149
        if (Filesystem\Path::isRelative($configFile)) {
22✔
150
            $configFile = Filesystem\Path::makeAbsolute($configFile, $rootPath);
1✔
151
        } else {
152
            $rootPath = dirname($configFile);
21✔
153
        }
154

155
        // Register custom error handler to collect deprecations from config presets
156
        $this->deprecationHandler->enable();
22✔
157

158
        try {
159
            $config = $this->configReader->readFromFile($configFile);
22✔
160

161
            // Override root path from config file
162
            if (null !== $config->rootPath()) {
21✔
163
                $rootPath = $config->rootPath();
21✔
164
            }
165

166
            // Auto-detect version range from indicators
167
            $versionRange = $this->resolveVersionRange($config, $rangeOrVersion, $rootPath);
21✔
168

169
            if (null === $versionRange) {
20✔
170
                return self::FAILURE;
2✔
171
            }
172

173
            $results = $this->bumpVersions($config, $versionRange, $rootPath, $dryRun);
18✔
174

175
            if (null === $results) {
14✔
176
                return self::FAILURE;
3✔
177
            }
178

179
            $this->decorateVersionBumpResults($results, $rootPath);
11✔
180

181
            if ($release && !$this->releaseVersion($results, $rootPath, $config->releaseOptions(), $versionRange, $dryRun)) {
11✔
182
                return self::FAILURE;
10✔
183
            }
184
        } catch (Valinor\Mapper\MappingError $error) {
7✔
185
            $this->decorateMappingError($error, $configFile);
1✔
186

187
            return self::FAILURE;
1✔
188
        } catch (Exception\Exception $exception) {
6✔
189
            $this->io->error($exception->getMessage());
6✔
190

191
            return self::FAILURE;
6✔
192
        } finally {
193
            if ($dryRun) {
22✔
194
                $this->io->note('No write operations were performed (dry-run mode).');
9✔
195
            }
196

197
            $this->deprecationHandler->disable();
22✔
198
            $this->deprecationHandler->decorate($this->io);
22✔
199
        }
200

201
        if ($strict) {
10✔
202
            foreach ($results as $versionBumpResult) {
1✔
203
                if ($versionBumpResult->hasUnmatchedOperations()) {
1✔
204
                    return self::FAILURE;
1✔
205
                }
206
            }
207
        }
208

209
        return self::SUCCESS;
9✔
210
    }
211

212
    private function resolveVersionRange(
21✔
213
        Config\VersionBumperConfig $config,
214
        ?string $rangeOrVersion,
215
        string $rootPath,
216
    ): Enum\VersionRange|string|null {
217
        if (null !== $rangeOrVersion) {
21✔
218
            $versionRange = Enum\VersionRange::tryFromInput($rangeOrVersion) ?? $rangeOrVersion;
17✔
219
        } elseif ([] !== $config->versionRangeIndicators()) {
4✔
220
            $versionRange = $this->versionRangeDetector->detect($rootPath, $config->versionRangeIndicators());
3✔
221
        } else {
222
            $this->io->error('Please provide a version range or explicit version to bump in configured files.');
1✔
223
            $this->io->block(
1✔
224
                'You can also enable auto-detection by adding version range indicators to your configuration file.',
1✔
225
                null,
1✔
226
                'fg=cyan',
1✔
227
                '💡 ',
1✔
228
            );
1✔
229

230
            return null;
1✔
231
        }
232

233
        // Exit early if version range detection fails
234
        if (null === $versionRange) {
19✔
235
            $this->io->error('Unable to auto-detect version range. Please provide a version range or explicit version instead.');
1✔
236

237
            return null;
1✔
238
        }
239

240
        return $versionRange;
18✔
241
    }
242

243
    /**
244
     * @return list<Result\VersionBumpResult>|null
245
     *
246
     * @throws Throwable
247
     */
248
    private function bumpVersions(
18✔
249
        Config\VersionBumperConfig $config,
250
        Enum\VersionRange|string $versionRange,
251
        string $rootPath,
252
        bool $dryRun,
253
    ): ?array {
254
        $results = [];
18✔
255

256
        $this->decorateAppliedPresets($config->presets());
18✔
257

258
        if ($this->io->isVerbose()) {
18✔
259
            $this->io->title('Running version bumper');
3✔
260
        }
261

262
        // Execute pre-actions
263
        if (!$dryRun && !$this->executeActions($config, Version\Action\ActionType::PreAction, $results, $rootPath)) {
18✔
264
            return null;
2✔
265
        }
266

267
        // Bump versions
268
        $versionBumpResults = $this->taskRunner->run(
16✔
269
            'Bumping versions in files',
16✔
270
            function (TaskRunner\RunnerContext $context) use ($config, $rootPath, $versionRange, $dryRun) {
16✔
271
                $results = $this->bumper->bump($config->filesToModify(), $rootPath, $versionRange, $dryRun);
16✔
272

273
                if ([] === $results) {
12✔
274
                    $context->statusMessage = '<comment>Skipped</comment>';
1✔
275
                }
276

277
                return $results;
12✔
278
            },
16✔
279
        );
16✔
280

281
        // Merged results from version bump with global results
282
        $this->mergeResults($versionBumpResults, $results, $rootPath);
12✔
283

284
        // Execute post-actions
285
        if (!$dryRun && !$this->executeActions($config, Version\Action\ActionType::PostAction, $results, $rootPath)) {
12✔
286
            return null;
1✔
287
        }
288

289
        return $results;
11✔
290
    }
291

292
    /**
293
     * @param list<Result\VersionBumpResult> $results
294
     */
295
    private function executeActions(
9✔
296
        Config\VersionBumperConfig $config,
297
        Version\Action\ActionType $type,
298
        array &$results,
299
        string $rootPath,
300
    ): bool {
301
        if (!$config->hasActions($type)) {
9✔
302
            return true;
7✔
303
        }
304

305
        try {
306
            return $this->taskRunner->run(
7✔
307
                sprintf('Executing %s', $type->label(true)),
7✔
308
                function (TaskRunner\RunnerContext $context) use ($config, &$results, $rootPath, $type): bool {
7✔
309
                    $dispatcher = new Version\ActionDispatcher($rootPath, $this->io);
7✔
310

311
                    // Consider only files with matched operations for post-actions,
312
                    // otherwise take all configured files into account
313
                    if (Version\Action\ActionType::PostAction === $type) {
7✔
314
                        $filesToConsider = array_map(
4✔
315
                            static fn (Result\VersionBumpResult $result) => $result->file(),
4✔
316
                            array_filter(
4✔
317
                                $results,
4✔
318
                                static fn (Result\VersionBumpResult $result) => $result->hasMatchedOperations(),
4✔
319
                            ),
4✔
320
                        );
4✔
321
                    } else {
322
                        $filesToConsider = $config->filesToModify();
3✔
323
                    }
324

325
                    foreach ($filesToConsider as $fileToModify) {
7✔
326
                        $actionExecutionResult = $dispatcher->dispatchAll(
7✔
327
                            $fileToModify->getActionsByType($type),
7✔
328
                            $fileToModify,
7✔
329
                        );
7✔
330

331
                        if ($actionExecutionResult->failed()) {
7✔
332
                            if ($context->output->isVerbose() && $actionExecutionResult->hasOutput()) {
3✔
333
                                $context->output->write($actionExecutionResult->output());
1✔
334
                            }
335

336
                            throw new Exception\ActionExecutionFailed($actionExecutionResult);
3✔
337
                        }
338

339
                        $this->mergeResults($actionExecutionResult->results(), $results, $rootPath);
4✔
340
                    }
341

342
                    return true;
4✔
343
                },
7✔
344
            );
7✔
345
        } catch (Exception\ActionExecutionFailed) {
3✔
346
            $this->io->error(
3✔
347
                sprintf('An error occured while executing %s.', $type->label(true)),
3✔
348
            );
3✔
349

350
            return false;
3✔
UNCOV
351
        } catch (Throwable $exception) {
×
UNCOV
352
            $this->io->error($exception->getMessage());
×
353

UNCOV
354
            return false;
×
355
        }
356
    }
357

358
    /**
359
     * @param list<Result\VersionBumpResult> $results
360
     *
361
     * @throws Exception\Exception
362
     */
363
    private function releaseVersion(
2✔
364
        array $results,
365
        string $rootPath,
366
        Config\ReleaseOptions $options,
367
        Enum\VersionRange|string $versionRange,
368
        bool $dryRun,
369
    ): bool {
370
        $this->io->title('Release');
2✔
371

372
        try {
373
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $versionRange, $dryRun);
2✔
374

375
            $this->decorateVersionReleaseResult($releaseResult);
1✔
376

377
            return true;
1✔
378
        } catch (Exception\Exception $exception) {
1✔
379
            throw $exception;
1✔
UNCOV
380
        } catch (\Exception $exception) {
×
UNCOV
381
            $this->io->error('Git error during release: '.$exception->getMessage());
×
382
        }
383

UNCOV
384
        return false;
×
385
    }
386

387
    /**
388
     * @param list<Result\VersionBumpResult> $source
389
     * @param list<Result\VersionBumpResult> $target
390
     */
391
    private function mergeResults(array $source, array &$target, string $rootPath): void
12✔
392
    {
393
        foreach ($source as $sourceResult) {
12✔
394
            $finalResult = null;
11✔
395

396
            foreach ($target as $targetResult) {
11✔
397
                if ($targetResult->file()->equals($sourceResult->file(), $rootPath)) {
8✔
398
                    $finalResult = $targetResult;
1✔
399
                    break;
1✔
400
                }
401
            }
402

403
            if (null !== $finalResult) {
11✔
404
                $finalResult->merge($sourceResult);
1✔
405
            } else {
406
                $target[] = $sourceResult;
11✔
407
            }
408
        }
409
    }
410

411
    /**
412
     * @param list<Config\Preset\Preset> $presets
413
     */
414
    private function decorateAppliedPresets(array $presets): void
18✔
415
    {
416
        if ([] === $presets || !$this->io->isVerbose()) {
18✔
417
            return;
17✔
418
        }
419

420
        $this->io->title('Applied presets');
1✔
421

422
        $this->io->listing(
1✔
423
            array_map(
1✔
424
                static fn (Config\Preset\Preset $preset) => sprintf(
1✔
425
                    '%s <fg=gray>(%s)</>',
1✔
426
                    $preset::getDescription(),
1✔
427
                    $preset::getIdentifier(),
1✔
428
                ),
1✔
429
                $presets,
1✔
430
            ),
1✔
431
        );
1✔
432
    }
433

434
    /**
435
     * @param list<Result\VersionBumpResult> $results
436
     */
437
    private function decorateVersionBumpResults(array $results, string $rootPath): void
11✔
438
    {
439
        $titleDisplayed = false;
11✔
440

441
        usort(
11✔
442
            $results,
11✔
443
            static fn (
11✔
444
                Result\VersionBumpResult $a,
11✔
445
                Result\VersionBumpResult $b,
11✔
446
            ) => $a->file()->fullPath($rootPath) <=> $b->file()->fullPath($rootPath),
8✔
447
        );
11✔
448

449
        foreach ($results as $result) {
11✔
450
            if (!$result->hasOperations()) {
10✔
451
                continue;
4✔
452
            }
453

454
            $path = $result->file()->path();
10✔
455
            $groupedOperations = $result->groupedOperations();
10✔
456
            $hasOnlySkippedOperations = [] === array_filter(
10✔
457
                $groupedOperations,
10✔
458
                static fn (array $operations) => Enum\OperationState::Skipped !== reset($operations)->state(),
10✔
459
            );
10✔
460

461
            if (Filesystem\Path::isAbsolute($path)) {
10✔
462
                $path = Filesystem\Path::makeRelative($path, $rootPath);
3✔
463
            }
464

465
            if (!$titleDisplayed) {
10✔
466
                $this->io->title('Bumped versions');
10✔
467
                $titleDisplayed = true;
10✔
468
            }
469

470
            $this->io->section($path);
10✔
471

472
            foreach ($groupedOperations as $operations) {
10✔
473
                $operation = reset($operations);
10✔
474
                $numberOfOperations = count($operations);
10✔
475
                $state = $operation->state();
10✔
476

477
                if (Enum\OperationState::Skipped === $state && !$hasOnlySkippedOperations) {
10✔
478
                    continue;
3✔
479
                }
480

481
                $message = match ($state) {
10✔
482
                    Enum\OperationState::Modified => sprintf(
10✔
483
                        '✅ Bumped version from "%s" to "%s"',
10✔
484
                        $operation->source()?->full() ?? '',
10✔
485
                        $operation->target()?->full() ?? '',
10✔
486
                    ),
10✔
487
                    Enum\OperationState::Regenerated => '🔁 Regenerated lock file (via post-action)',
8✔
488
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
489
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()?->original(),
4✔
490
                };
10✔
491

492
                if ($numberOfOperations > 1) {
10✔
493
                    $message .= sprintf(' (%dx)', $numberOfOperations);
3✔
494
                }
495

496
                $this->io->writeln($message);
10✔
497
            }
498
        }
499
    }
500

501
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
502
    {
503
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
504
        $releaseInformation = [];
1✔
505

506
        if ($numberOfCommittedFiles > 0) {
1✔
507
            $releaseInformation[] = sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : '');
1✔
508
        }
509

510
        if (null !== $result->commitMessage()) {
1✔
511
            $releaseInformation[] = sprintf('Committed: <info>%s</info>', $result->commitMessage());
1✔
512
        }
513

514
        if (null !== $result->commitId()) {
1✔
UNCOV
515
            $releaseInformation[] = sprintf('Commit hash: <info>%s</info>', $result->commitId());
×
516
        }
517

518
        $releaseInformation[] = sprintf('Tagged: <info>%s</info>', $result->tagName());
1✔
519

520
        $this->io->listing($releaseInformation);
1✔
521
    }
522

523
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
1✔
524
    {
525
        $errorMessages = [];
1✔
526
        $errors = $error->messages()->errors();
1✔
527

528
        $this->io->error(
1✔
529
            sprintf('The config file "%s" is invalid.', $configFile),
1✔
530
        );
1✔
531

532
        foreach ($errors as $propertyError) {
1✔
533
            $errorMessages[] = sprintf('%s: %s', $propertyError->path(), $propertyError->toString());
1✔
534
        }
535

536
        $this->io->listing($errorMessages);
1✔
537
    }
538

539
    private function readConfigFileFromRootPackage(): ?string
23✔
540
    {
541
        $composer = $this->getComposerInstance();
23✔
542

543
        if (null === $composer) {
23✔
544
            return null;
23✔
545
        }
546

547
        $extra = $composer->getPackage()->getExtra();
1✔
548
        /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
549
        $configFile = $extra['version-bumper']['config-file'] ?? null;
1✔
550

551
        if (is_string($configFile) && '' !== trim($configFile)) {
1✔
552
            return $configFile;
1✔
553
        }
554

UNCOV
555
        return null;
×
556
    }
557

558
    private function getComposerInstance(): ?Composer
23✔
559
    {
560
        // Composer >= 2.3
561
        if (method_exists($this, 'tryComposer')) {
23✔
562
            return $this->tryComposer();
23✔
563
        }
564

565
        // Composer < 2.3
UNCOV
566
        return $this->getComposer(false);
×
567
    }
568
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc