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

eliashaeussler / version-bumper / 24529462145

16 Apr 2026 07:22PM UTC coverage: 88.084% (+0.09%) from 87.996%
24529462145

push

github

web-flow
Merge pull request #125 from eliashaeussler/feature/actions

[FEATURE] Introduce actions

242 of 289 new or added lines in 20 files covered. (83.74%)

2 existing lines in 2 files now uncovered.

1094 of 1242 relevant lines covered (88.08%)

4.98 hits per line

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

96.51
/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\Exception;
33
use EliasHaeussler\VersionBumper\Result;
34
use EliasHaeussler\VersionBumper\Version;
35
use GitElephant\Command\Caller;
36
use Symfony\Component\Console;
37
use Symfony\Component\Filesystem;
38
use Throwable;
39

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

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

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

76
        parent::__construct('bump-version');
21✔
77

78
        $this->bumper = new Version\VersionBumper();
21✔
79
        $this->configReader = new Config\ConfigReader();
21✔
80
        $this->versionRangeDetector = new Version\VersionRangeDetector($caller);
21✔
81
        $this->releaser = new Version\VersionReleaser($caller);
21✔
82
    }
83

84
    protected function configure(): void
21✔
85
    {
86
        $this->setAliases(['bv']);
21✔
87
        $this->setDescription('Bump package version in specific files during release preparations');
21✔
88

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

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

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

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

140
        if (null === $configFile) {
21✔
141
            $this->io->error('Please provide a config file path using the --config option.');
1✔
142

143
            return self::INVALID;
1✔
144
        }
145

146
        if (Filesystem\Path::isRelative($configFile)) {
20✔
147
            $configFile = Filesystem\Path::makeAbsolute($configFile, $rootPath);
1✔
148
        } else {
149
            $rootPath = dirname($configFile);
19✔
150
        }
151

152
        try {
153
            $config = $this->configReader->readFromFile($configFile);
20✔
154

155
            // Override root path from config file
156
            if (null !== $config->rootPath()) {
19✔
157
                $rootPath = $config->rootPath();
19✔
158
            }
159

160
            $results = $this->bumpVersions($config, $rangeOrVersion, $rootPath, $dryRun);
19✔
161

162
            if (null === $results) {
14✔
163
                return self::FAILURE;
5✔
164
            }
165

166
            $this->decorateVersionBumpResults($results, $rootPath);
9✔
167

168
            if ($release && !$this->releaseVersion($results, $rootPath, $config->releaseOptions(), $dryRun)) {
9✔
169
                return self::FAILURE;
8✔
170
            }
171
        } catch (Valinor\Mapper\MappingError $error) {
7✔
172
            $this->decorateMappingError($error, $configFile);
1✔
173

174
            return self::FAILURE;
1✔
175
        } catch (Exception\Exception $exception) {
6✔
176
            $this->io->error($exception->getMessage());
6✔
177

178
            return self::FAILURE;
6✔
179
        } finally {
180
            if ($dryRun) {
20✔
181
                $this->io->note('No write operations were performed (dry-run mode).');
7✔
182
            }
183
        }
184

185
        if ($strict) {
8✔
186
            foreach ($results as $versionBumpResult) {
1✔
187
                if ($versionBumpResult->hasUnmatchedOperations()) {
1✔
188
                    return self::FAILURE;
1✔
189
                }
190
            }
191
        }
192

193
        return self::SUCCESS;
7✔
194
    }
195

196
    /**
197
     * @return list<Result\VersionBumpResult>|null
198
     *
199
     * @throws Throwable
200
     */
201
    private function bumpVersions(
19✔
202
        Config\VersionBumperConfig $config,
203
        ?string $rangeOrVersion,
204
        string $rootPath,
205
        bool $dryRun,
206
    ): ?array {
207
        $results = [];
19✔
208

209
        // Auto-detect version range from indicators
210
        if (null !== $rangeOrVersion) {
19✔
211
            $versionRange = Enum\VersionRange::tryFromInput($rangeOrVersion) ?? $rangeOrVersion;
15✔
212
        } elseif ([] !== $config->versionRangeIndicators()) {
4✔
213
            $versionRange = $this->versionRangeDetector->detect($rootPath, $config->versionRangeIndicators());
3✔
214
        } else {
215
            $this->io->error('Please provide a version range or explicit version to bump in configured files.');
1✔
216
            $this->io->block(
1✔
217
                'You can also enable auto-detection by adding version range indicators to your configuration file.',
1✔
218
                null,
1✔
219
                'fg=cyan',
1✔
220
                '💡 ',
1✔
221
            );
1✔
222

223
            return null;
1✔
224
        }
225

226
        // Exit early if version range detection fails
227
        if (null === $versionRange) {
17✔
228
            $this->io->error('Unable to auto-detect version range. Please provide a version range or explicit version instead.');
1✔
229

230
            return null;
1✔
231
        }
232

233
        $this->decorateAppliedPresets($config->presets());
16✔
234

235
        if ($this->io->isVerbose()) {
16✔
236
            $this->io->title('Running version bumper');
3✔
237
        }
238

239
        // Execute pre-actions
240
        if (!$dryRun && !$this->executeActions($config, Version\Action\ActionType::PreAction, $results, $rootPath)) {
16✔
241
            return null;
2✔
242
        }
243

244
        // Bump versions
245
        $versionBumpResults = $this->taskRunner->run(
14✔
246
            'Bumping versions in files',
14✔
247
            fn () => $this->bumper->bump($config->filesToModify(), $rootPath, $versionRange, $dryRun),
14✔
248
        );
14✔
249

250
        // Merged results from version bump with global results
251
        $this->mergeResults($versionBumpResults, $results, $rootPath);
10✔
252

253
        // Execute post-actions
254
        if (!$dryRun && !$this->executeActions($config, Version\Action\ActionType::PostAction, $results, $rootPath)) {
10✔
255
            return null;
1✔
256
        }
257

258
        return $results;
9✔
259
    }
260

261
    /**
262
     * @param list<Result\VersionBumpResult> $results
263
     */
264
    private function executeActions(
9✔
265
        Config\VersionBumperConfig $config,
266
        Version\Action\ActionType $type,
267
        array &$results,
268
        string $rootPath,
269
    ): bool {
270
        if (!$config->hasActions($type)) {
9✔
271
            return true;
7✔
272
        }
273

274
        try {
275
            return $this->taskRunner->run(
7✔
276
                sprintf('Executing %s', $type->label(true)),
7✔
277
                function (TaskRunner\RunnerContext $context) use ($config, &$results, $rootPath, $type): bool {
7✔
278
                    $dispatcher = new Version\ActionDispatcher($rootPath, $this->io);
7✔
279

280
                    // Consider only files with matched operations for post-actions,
281
                    // otherwise take all configured files into account
282
                    if (Version\Action\ActionType::PostAction === $type) {
7✔
283
                        $filesToConsider = array_map(
4✔
284
                            static fn (Result\VersionBumpResult $result) => $result->file(),
4✔
285
                            array_filter(
4✔
286
                                $results,
4✔
287
                                static fn (Result\VersionBumpResult $result) => $result->hasMatchedOperations(),
4✔
288
                            ),
4✔
289
                        );
4✔
290
                    } else {
291
                        $filesToConsider = $config->filesToModify();
3✔
292
                    }
293

294
                    foreach ($filesToConsider as $fileToModify) {
7✔
295
                        $actionExecutionResult = $dispatcher->dispatchAll(
7✔
296
                            $fileToModify->getActionsByType($type),
7✔
297
                            $fileToModify,
7✔
298
                        );
7✔
299

300
                        if ($actionExecutionResult->failed()) {
7✔
301
                            if ($context->output->isVerbose() && $actionExecutionResult->hasOutput()) {
3✔
302
                                $context->output->write($actionExecutionResult->output());
1✔
303
                            }
304

305
                            throw new Exception\ActionExecutionFailed($actionExecutionResult);
3✔
306
                        }
307

308
                        $this->mergeResults($actionExecutionResult->results(), $results, $rootPath);
4✔
309
                    }
310

311
                    return true;
4✔
312
                },
7✔
313
            );
7✔
314
        } catch (Exception\ActionExecutionFailed) {
3✔
315
            $this->io->error(
3✔
316
                sprintf('An error occured while executing %s.', $type->label(true)),
3✔
317
            );
3✔
318

319
            return false;
3✔
NEW
320
        } catch (Throwable $exception) {
×
NEW
321
            $this->io->error($exception->getMessage());
×
322

NEW
323
            return false;
×
324
        }
325
    }
326

327
    /**
328
     * @param list<Result\VersionBumpResult> $results
329
     *
330
     * @throws Exception\Exception
331
     */
332
    private function releaseVersion(array $results, string $rootPath, Config\ReleaseOptions $options, bool $dryRun): bool
2✔
333
    {
334
        $this->io->title('Release');
2✔
335

336
        try {
337
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $dryRun);
2✔
338

339
            $this->decorateVersionReleaseResult($releaseResult);
1✔
340

341
            return true;
1✔
342
        } catch (Exception\Exception $exception) {
1✔
343
            throw $exception;
1✔
344
        } catch (\Exception $exception) {
×
345
            $this->io->error('Git error during release: '.$exception->getMessage());
×
346
        }
347

348
        return false;
×
349
    }
350

351
    /**
352
     * @param list<Result\VersionBumpResult> $source
353
     * @param list<Result\VersionBumpResult> $target
354
     */
355
    private function mergeResults(array $source, array &$target, string $rootPath): void
10✔
356
    {
357
        foreach ($source as $sourceResult) {
10✔
358
            $finalResult = null;
10✔
359

360
            foreach ($target as $targetResult) {
10✔
361
                if ($targetResult->file()->equals($sourceResult->file(), $rootPath)) {
8✔
362
                    $finalResult = $targetResult;
1✔
363
                    break;
1✔
364
                }
365
            }
366

367
            if (null !== $finalResult) {
10✔
368
                $finalResult->merge($sourceResult);
1✔
369
            } else {
370
                $target[] = $sourceResult;
10✔
371
            }
372
        }
373
    }
374

375
    /**
376
     * @param list<Config\Preset\Preset> $presets
377
     */
378
    private function decorateAppliedPresets(array $presets): void
16✔
379
    {
380
        if ([] === $presets || !$this->io->isVerbose()) {
16✔
381
            return;
15✔
382
        }
383

384
        $this->io->title('Applied presets');
1✔
385

386
        $this->io->listing(
1✔
387
            array_map(
1✔
388
                static fn (Config\Preset\Preset $preset) => sprintf(
1✔
389
                    '%s <fg=gray>(%s)</>',
1✔
390
                    $preset::getDescription(),
1✔
391
                    $preset::getIdentifier(),
1✔
392
                ),
1✔
393
                $presets,
1✔
394
            ),
1✔
395
        );
1✔
396
    }
397

398
    /**
399
     * @param list<Result\VersionBumpResult> $results
400
     */
401
    private function decorateVersionBumpResults(array $results, string $rootPath): void
9✔
402
    {
403
        $titleDisplayed = false;
9✔
404

405
        usort(
9✔
406
            $results,
9✔
407
            static fn (
9✔
408
                Result\VersionBumpResult $a,
9✔
409
                Result\VersionBumpResult $b,
9✔
410
            ) => $a->file()->fullPath($rootPath) <=> $b->file()->fullPath($rootPath),
8✔
411
        );
9✔
412

413
        foreach ($results as $result) {
9✔
414
            if (!$result->hasOperations()) {
9✔
415
                continue;
4✔
416
            }
417

418
            $path = $result->file()->path();
9✔
419
            $groupedOperations = $result->groupedOperations();
9✔
420
            $hasOnlySkippedOperations = [] === array_filter(
9✔
421
                $groupedOperations,
9✔
422
                static fn (array $operations) => Enum\OperationState::Skipped !== reset($operations)->state(),
9✔
423
            );
9✔
424

425
            if (Filesystem\Path::isAbsolute($path)) {
9✔
426
                $path = Filesystem\Path::makeRelative($path, $rootPath);
3✔
427
            }
428

429
            if (!$titleDisplayed) {
9✔
430
                $this->io->title('Bumped versions');
9✔
431
                $titleDisplayed = true;
9✔
432
            }
433

434
            $this->io->section($path);
9✔
435

436
            foreach ($groupedOperations as $operations) {
9✔
437
                $operation = reset($operations);
9✔
438
                $numberOfOperations = count($operations);
9✔
439
                $state = $operation->state();
9✔
440

441
                if (Enum\OperationState::Skipped === $state && !$hasOnlySkippedOperations) {
9✔
442
                    continue;
3✔
443
                }
444

445
                $message = match ($state) {
9✔
446
                    Enum\OperationState::Modified => sprintf(
9✔
447
                        '✅ Bumped version from "%s" to "%s"',
9✔
448
                        $operation->source()?->full() ?? '',
9✔
449
                        $operation->target()?->full() ?? '',
9✔
450
                    ),
9✔
451
                    Enum\OperationState::Regenerated => '🔁 Regenerated lock file (via post-action)',
8✔
452
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
453
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()?->original(),
4✔
454
                };
9✔
455

456
                if ($numberOfOperations > 1) {
9✔
457
                    $message .= sprintf(' (%dx)', $numberOfOperations);
3✔
458
                }
459

460
                $this->io->writeln($message);
9✔
461
            }
462
        }
463
    }
464

465
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
466
    {
467
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
468
        $releaseInformation = [
1✔
469
            sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : ''),
1✔
470
            sprintf('Committed: <info>%s</info>', $result->commitMessage()),
1✔
471
        ];
1✔
472

473
        if (null !== $result->commitId()) {
1✔
474
            $releaseInformation[] = sprintf('Commit hash: %s', $result->commitId());
×
475
        }
476

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

479
        $this->io->listing($releaseInformation);
1✔
480
    }
481

482
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
1✔
483
    {
484
        $errorMessages = [];
1✔
485
        $errors = $error->messages()->errors();
1✔
486

487
        $this->io->error(
1✔
488
            sprintf('The config file "%s" is invalid.', $configFile),
1✔
489
        );
1✔
490

491
        foreach ($errors as $propertyError) {
1✔
492
            $errorMessages[] = sprintf('%s: %s', $propertyError->path(), $propertyError->toString());
1✔
493
        }
494

495
        $this->io->listing($errorMessages);
1✔
496
    }
497

498
    private function readConfigFileFromRootPackage(): ?string
21✔
499
    {
500
        $composer = $this->getComposerInstance();
21✔
501

502
        if (null === $composer) {
21✔
503
            return null;
21✔
504
        }
505

506
        $extra = $composer->getPackage()->getExtra();
1✔
507
        /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
508
        $configFile = $extra['version-bumper']['config-file'] ?? null;
1✔
509

510
        if (is_string($configFile) && '' !== trim($configFile)) {
1✔
511
            return $configFile;
1✔
512
        }
513

514
        return null;
×
515
    }
516

517
    private function getComposerInstance(): ?Composer
21✔
518
    {
519
        // Composer >= 2.3
520
        if (method_exists($this, 'tryComposer')) {
21✔
521
            return $this->tryComposer();
21✔
522
        }
523

524
        // Composer < 2.3
525
        return $this->getComposer(false);
×
526
    }
527
}
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