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

eliashaeussler / version-bumper / 25884562147

14 May 2026 08:42PM UTC coverage: 88.576% (+0.1%) from 88.43%
25884562147

Pull #138

github

eliashaeussler
[!!!][FEATURE] Allow Git tagging without modified files
Pull Request #138: [!!!][FEATURE] Allow Git tagging without modified files

77 of 82 new or added lines in 7 files covered. (93.9%)

1194 of 1348 relevant lines covered (88.58%)

5.17 hits per line

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

96.92
/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 restore_error_handler;
51
use function set_error_handler;
52
use function sprintf;
53
use function trim;
54
use function usort;
55

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

71
    /**
72
     * @var list<Error\DeprecationMessage>
73
     */
74
    private array $deprecations = [];
75

76
    public function __construct(
22✔
77
        ?Composer $composer = null,
78
        ?Caller\CallerInterface $caller = null,
79
    ) {
80
        if (null !== $composer) {
22✔
81
            $this->setComposer($composer);
1✔
82
        }
83

84
        parent::__construct('bump-version');
22✔
85

86
        $this->bumper = new Version\VersionBumper();
22✔
87
        $this->configReader = new Config\ConfigReader();
22✔
88
        $this->versionRangeDetector = new Version\VersionRangeDetector($caller);
22✔
89
        $this->releaser = new Version\VersionReleaser($caller);
22✔
90
    }
91

92
    protected function configure(): void
22✔
93
    {
94
        $this->setAliases(['bv']);
22✔
95
        $this->setDescription('Bump package version in specific files during release preparations');
22✔
96

97
        $this->addArgument(
22✔
98
            'range',
22✔
99
            Console\Input\InputArgument::OPTIONAL,
22✔
100
            sprintf(
22✔
101
                'Version range (one of "%s") or explicit version to bump in configured files',
22✔
102
                implode('", "', Enum\VersionRange::all()),
22✔
103
            ),
22✔
104
        );
22✔
105

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

133
    protected function initialize(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): void
22✔
134
    {
135
        $this->io = new Console\Style\SymfonyStyle($input, $output);
22✔
136
        $this->taskRunner = new TaskRunner\TaskRunner($this->io);
22✔
137
        $this->deprecations = [];
22✔
138
    }
139

140
    protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
22✔
141
    {
142
        $rootPath = (string) getcwd();
22✔
143
        $rangeOrVersion = $input->getArgument('range');
22✔
144
        $configFile = $input->getOption('config') ?? $this->configReader->detectFile($rootPath);
22✔
145
        $release = $input->getOption('release');
22✔
146
        $dryRun = $input->getOption('dry-run');
22✔
147
        $strict = $input->getOption('strict');
22✔
148

149
        if (null === $configFile) {
22✔
150
            $this->io->error('Please provide a config file path using the --config option.');
1✔
151

152
            return self::INVALID;
1✔
153
        }
154

155
        if (Filesystem\Path::isRelative($configFile)) {
21✔
156
            $configFile = Filesystem\Path::makeAbsolute($configFile, $rootPath);
1✔
157
        } else {
158
            $rootPath = dirname($configFile);
20✔
159
        }
160

161
        // Register custom error handler to collect deprecations from config presets
162
        set_error_handler($this->collectDeprecations(...), E_USER_DEPRECATED);
21✔
163

164
        try {
165
            $config = $this->configReader->readFromFile($configFile);
21✔
166

167
            // Override root path from config file
168
            if (null !== $config->rootPath()) {
20✔
169
                $rootPath = $config->rootPath();
20✔
170
            }
171

172
            // Auto-detect version range from indicators
173
            $versionRange = $this->resolveVersionRange($config, $rangeOrVersion, $rootPath);
20✔
174

175
            if (null === $versionRange) {
19✔
176
                return self::FAILURE;
2✔
177
            }
178

179
            $results = $this->bumpVersions($config, $versionRange, $rootPath, $dryRun);
17✔
180

181
            if (null === $results) {
13✔
182
                return self::FAILURE;
3✔
183
            }
184

185
            $this->decorateVersionBumpResults($results, $rootPath);
10✔
186

187
            if ($release && !$this->releaseVersion($results, $rootPath, $config->releaseOptions(), $versionRange, $dryRun)) {
10✔
188
                return self::FAILURE;
9✔
189
            }
190
        } catch (Valinor\Mapper\MappingError $error) {
7✔
191
            $this->decorateMappingError($error, $configFile);
1✔
192

193
            return self::FAILURE;
1✔
194
        } catch (Exception\Exception $exception) {
6✔
195
            $this->io->error($exception->getMessage());
6✔
196

197
            return self::FAILURE;
6✔
198
        } finally {
199
            // Restore original error handler
200
            restore_error_handler();
21✔
201

202
            if ($dryRun) {
21✔
203
                $this->io->note('No write operations were performed (dry-run mode).');
8✔
204
            }
205

206
            $this->decorateDeprecationMessages();
21✔
207
        }
208

209
        if ($strict) {
9✔
210
            foreach ($results as $versionBumpResult) {
1✔
211
                if ($versionBumpResult->hasUnmatchedOperations()) {
1✔
212
                    return self::FAILURE;
1✔
213
                }
214
            }
215
        }
216

217
        return self::SUCCESS;
8✔
218
    }
219

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

238
            return null;
1✔
239
        }
240

241
        // Exit early if version range detection fails
242
        if (null === $versionRange) {
18✔
243
            $this->io->error('Unable to auto-detect version range. Please provide a version range or explicit version instead.');
1✔
244

245
            return null;
1✔
246
        }
247

248
        return $versionRange;
17✔
249
    }
250

251
    /**
252
     * @return list<Result\VersionBumpResult>|null
253
     *
254
     * @throws Throwable
255
     */
256
    private function bumpVersions(
17✔
257
        Config\VersionBumperConfig $config,
258
        Enum\VersionRange|string $versionRange,
259
        string $rootPath,
260
        bool $dryRun,
261
    ): ?array {
262
        $results = [];
17✔
263

264
        $this->decorateAppliedPresets($config->presets());
17✔
265

266
        if ($this->io->isVerbose()) {
17✔
267
            $this->io->title('Running version bumper');
3✔
268
        }
269

270
        // Execute pre-actions
271
        if (!$dryRun && !$this->executeActions($config, Version\Action\ActionType::PreAction, $results, $rootPath)) {
17✔
272
            return null;
2✔
273
        }
274

275
        // Bump versions
276
        $versionBumpResults = $this->taskRunner->run(
15✔
277
            'Bumping versions in files',
15✔
278
            fn () => $this->bumper->bump($config->filesToModify(), $rootPath, $versionRange, $dryRun),
15✔
279
        );
15✔
280

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

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

289
        return $results;
10✔
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✔
351
        } catch (Throwable $exception) {
×
352
            $this->io->error($exception->getMessage());
×
353

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✔
380
        } catch (\Exception $exception) {
×
381
            $this->io->error('Git error during release: '.$exception->getMessage());
×
382
        }
383

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
11✔
392
    {
393
        foreach ($source as $sourceResult) {
11✔
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
    private function decorateDeprecationMessages(): void
21✔
412
    {
413
        if ([] === $this->deprecations) {
21✔
414
            return;
20✔
415
        }
416

417
        $this->io->warning('Your config file contains deprecated options.');
1✔
418

419
        $this->io->listing(
1✔
420
            array_map(
1✔
421
                static fn (Error\DeprecationMessage $deprecation) => implode('', [
1✔
422
                    $deprecation->origin() instanceof Config\Preset\Preset
1✔
423
                        ? sprintf('<fg=yellow>%s</>: ', $deprecation->origin()::getIdentifier())
1✔
424
                        : '',
1✔
425
                    $deprecation->message(),
1✔
426
                    null !== $deprecation->since()
1✔
427
                        ? sprintf(' <fg=yellow>(deprecated since v%s)</>', $deprecation->since())
1✔
428
                        : '',
1✔
429
                ]),
1✔
430
                $this->deprecations,
1✔
431
            ),
1✔
432
        );
1✔
433
    }
434

435
    /**
436
     * @param list<Config\Preset\Preset> $presets
437
     */
438
    private function decorateAppliedPresets(array $presets): void
17✔
439
    {
440
        if ([] === $presets || !$this->io->isVerbose()) {
17✔
441
            return;
16✔
442
        }
443

444
        $this->io->title('Applied presets');
1✔
445

446
        $this->io->listing(
1✔
447
            array_map(
1✔
448
                static fn (Config\Preset\Preset $preset) => sprintf(
1✔
449
                    '%s <fg=gray>(%s)</>',
1✔
450
                    $preset::getDescription(),
1✔
451
                    $preset::getIdentifier(),
1✔
452
                ),
1✔
453
                $presets,
1✔
454
            ),
1✔
455
        );
1✔
456
    }
457

458
    /**
459
     * @param list<Result\VersionBumpResult> $results
460
     */
461
    private function decorateVersionBumpResults(array $results, string $rootPath): void
10✔
462
    {
463
        $titleDisplayed = false;
10✔
464

465
        usort(
10✔
466
            $results,
10✔
467
            static fn (
10✔
468
                Result\VersionBumpResult $a,
10✔
469
                Result\VersionBumpResult $b,
10✔
470
            ) => $a->file()->fullPath($rootPath) <=> $b->file()->fullPath($rootPath),
8✔
471
        );
10✔
472

473
        foreach ($results as $result) {
10✔
474
            if (!$result->hasOperations()) {
10✔
475
                continue;
4✔
476
            }
477

478
            $path = $result->file()->path();
10✔
479
            $groupedOperations = $result->groupedOperations();
10✔
480
            $hasOnlySkippedOperations = [] === array_filter(
10✔
481
                $groupedOperations,
10✔
482
                static fn (array $operations) => Enum\OperationState::Skipped !== reset($operations)->state(),
10✔
483
            );
10✔
484

485
            if (Filesystem\Path::isAbsolute($path)) {
10✔
486
                $path = Filesystem\Path::makeRelative($path, $rootPath);
3✔
487
            }
488

489
            if (!$titleDisplayed) {
10✔
490
                $this->io->title('Bumped versions');
10✔
491
                $titleDisplayed = true;
10✔
492
            }
493

494
            $this->io->section($path);
10✔
495

496
            foreach ($groupedOperations as $operations) {
10✔
497
                $operation = reset($operations);
10✔
498
                $numberOfOperations = count($operations);
10✔
499
                $state = $operation->state();
10✔
500

501
                if (Enum\OperationState::Skipped === $state && !$hasOnlySkippedOperations) {
10✔
502
                    continue;
3✔
503
                }
504

505
                $message = match ($state) {
10✔
506
                    Enum\OperationState::Modified => sprintf(
10✔
507
                        '✅ Bumped version from "%s" to "%s"',
10✔
508
                        $operation->source()?->full() ?? '',
10✔
509
                        $operation->target()?->full() ?? '',
10✔
510
                    ),
10✔
511
                    Enum\OperationState::Regenerated => '🔁 Regenerated lock file (via post-action)',
8✔
512
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
513
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()?->original(),
4✔
514
                };
10✔
515

516
                if ($numberOfOperations > 1) {
10✔
517
                    $message .= sprintf(' (%dx)', $numberOfOperations);
3✔
518
                }
519

520
                $this->io->writeln($message);
10✔
521
            }
522
        }
523
    }
524

525
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
526
    {
527
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
528
        $releaseInformation = [];
1✔
529

530
        if ($numberOfCommittedFiles > 0) {
1✔
531
            $releaseInformation[] = sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : '');
1✔
532
        }
533

534
        if (null !== $result->commitMessage()) {
1✔
535
            $releaseInformation[] = sprintf('Committed: <info>%s</info>', $result->commitMessage());
1✔
536
        }
537

538
        if (null !== $result->commitId()) {
1✔
NEW
539
            $releaseInformation[] = sprintf('Commit hash: <info>%s</info>', $result->commitId());
×
540
        }
541

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

544
        $this->io->listing($releaseInformation);
1✔
545
    }
546

547
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
1✔
548
    {
549
        $errorMessages = [];
1✔
550
        $errors = $error->messages()->errors();
1✔
551

552
        $this->io->error(
1✔
553
            sprintf('The config file "%s" is invalid.', $configFile),
1✔
554
        );
1✔
555

556
        foreach ($errors as $propertyError) {
1✔
557
            $errorMessages[] = sprintf('%s: %s', $propertyError->path(), $propertyError->toString());
1✔
558
        }
559

560
        $this->io->listing($errorMessages);
1✔
561
    }
562

563
    private function readConfigFileFromRootPackage(): ?string
22✔
564
    {
565
        $composer = $this->getComposerInstance();
22✔
566

567
        if (null === $composer) {
22✔
568
            return null;
22✔
569
        }
570

571
        $extra = $composer->getPackage()->getExtra();
1✔
572
        /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
573
        $configFile = $extra['version-bumper']['config-file'] ?? null;
1✔
574

575
        if (is_string($configFile) && '' !== trim($configFile)) {
1✔
576
            return $configFile;
1✔
577
        }
578

579
        return null;
×
580
    }
581

582
    private function collectDeprecations(int $errno, string $errstr): bool
1✔
583
    {
584
        $deprecationMessage = Error\DeprecationMessage::fromTraceOrMessage($errstr);
1✔
585
        $collected = null !== $deprecationMessage;
1✔
586

587
        if ($collected) {
1✔
588
            $this->deprecations[] = $deprecationMessage;
1✔
589
        }
590

591
        return $collected;
1✔
592
    }
593

594
    private function getComposerInstance(): ?Composer
22✔
595
    {
596
        // Composer >= 2.3
597
        if (method_exists($this, 'tryComposer')) {
22✔
598
            return $this->tryComposer();
22✔
599
        }
600

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

© 2026 Coveralls, Inc