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

eliashaeussler / version-bumper / 26181738960

20 May 2026 06:23PM UTC coverage: 81.215% (-7.4%) from 88.618%
26181738960

Pull #144

github

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

65 of 181 new or added lines in 6 files covered. (35.91%)

1 existing line in 1 file now uncovered.

1163 of 1432 relevant lines covered (81.22%)

4.96 hits per line

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

96.38
/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\Composer;
27
use EliasHaeussler\TaskRunner;
28
use EliasHaeussler\VersionBumper\Config;
29
use EliasHaeussler\VersionBumper\Enum;
30
use EliasHaeussler\VersionBumper\Exception;
31
use EliasHaeussler\VersionBumper\Result;
32
use EliasHaeussler\VersionBumper\Version;
33
use GitElephant\Command\Caller;
34
use Symfony\Component\Console;
35
use Symfony\Component\Filesystem;
36
use Throwable;
37

38
use function array_filter;
39
use function array_map;
40
use function count;
41
use function implode;
42
use function reset;
43
use function sprintf;
44
use function usort;
45

46
/**
47
 * BumpVersionCommand.
48
 *
49
 * @author Elias Häußler <elias@haeussler.dev>
50
 * @license GPL-3.0-or-later
51
 */
52
final class BumpVersionCommand extends BaseVersionCommand
53
{
54
    private readonly Version\VersionBumper $bumper;
55
    private readonly Version\VersionRangeDetector $versionRangeDetector;
56
    private readonly Version\VersionReleaser $releaser;
57
    private TaskRunner\TaskRunner $taskRunner;
58

59
    public function __construct(
23✔
60
        ?Composer $composer = null,
61
        ?Caller\CallerInterface $caller = null,
62
    ) {
63
        parent::__construct('bump-version', $composer);
23✔
64

65
        $this->bumper = new Version\VersionBumper();
23✔
66
        $this->versionRangeDetector = new Version\VersionRangeDetector($caller);
23✔
67
        $this->releaser = new Version\VersionReleaser($caller);
23✔
68
    }
69

70
    protected function configure(): void
23✔
71
    {
72
        parent::configure();
23✔
73

74
        $this->setAliases(['bv']);
23✔
75
        $this->setDescription('Bump package version in specific files during release preparations');
23✔
76

77
        $this->addArgument(
23✔
78
            'range',
23✔
79
            Console\Input\InputArgument::OPTIONAL,
23✔
80
            sprintf(
23✔
81
                'Version range (one of "%s") or explicit version to bump in configured files',
23✔
82
                implode('", "', Enum\VersionRange::all()),
23✔
83
            ),
23✔
84
        );
23✔
85

86
        $this->addOption(
23✔
87
            'release',
23✔
88
            'r',
23✔
89
            Console\Input\InputOption::VALUE_NONE,
23✔
90
            'Create a new Git tag after versions are bumped',
23✔
91
        );
23✔
92
        $this->addOption(
23✔
93
            'dry-run',
23✔
94
            null,
23✔
95
            Console\Input\InputOption::VALUE_NONE,
23✔
96
            'Do not perform any write operations, just calculate version bumps',
23✔
97
        );
23✔
98
        $this->addOption(
23✔
99
            'strict',
23✔
100
            null,
23✔
101
            Console\Input\InputOption::VALUE_NONE,
23✔
102
            'Fail if any unmatched file pattern is reported',
23✔
103
        );
23✔
104
    }
105

106
    protected function initialize(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): void
23✔
107
    {
108
        parent::initialize($input, $output);
23✔
109

110
        $this->taskRunner = new TaskRunner\TaskRunner($this->io);
23✔
111
    }
112

113
    protected function executeCommand(
21✔
114
        Config\VersionBumperConfig $config,
115
        string $rootPath,
116
        Console\Input\InputInterface $input,
117
        Console\Output\OutputInterface $output,
118
    ): int {
119
        $rangeOrVersion = $input->getArgument('range');
21✔
120
        $release = $input->getOption('release');
21✔
121
        $dryRun = $input->getOption('dry-run');
21✔
122
        $strict = $input->getOption('strict');
21✔
123

124
        try {
125
            // Auto-detect version range from indicators
126
            $versionRange = $this->resolveVersionRange($config, $rangeOrVersion, $rootPath);
21✔
127

128
            if (null === $versionRange) {
20✔
129
                return self::FAILURE;
2✔
130
            }
131

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

134
            if (null === $results) {
14✔
135
                return self::FAILURE;
3✔
136
            }
137

138
            $this->decorateVersionBumpResults($results, $rootPath);
11✔
139

140
            if ($release && !$this->releaseVersion($results, $rootPath, $config->releaseOptions(), $versionRange, $dryRun)) {
11✔
UNCOV
141
                return self::FAILURE;
×
142
            }
143
        } finally {
144
            if ($dryRun) {
21✔
145
                $this->io->note('No write operations were performed (dry-run mode).');
9✔
146
            }
147
        }
148

149
        if ($strict) {
10✔
150
            foreach ($results as $versionBumpResult) {
1✔
151
                if ($versionBumpResult->hasUnmatchedOperations()) {
1✔
152
                    return self::FAILURE;
1✔
153
                }
154
            }
155
        }
156

157
        return self::SUCCESS;
9✔
158
    }
159

160
    private function resolveVersionRange(
21✔
161
        Config\VersionBumperConfig $config,
162
        ?string $rangeOrVersion,
163
        string $rootPath,
164
    ): Enum\VersionRange|string|null {
165
        if (null !== $rangeOrVersion) {
21✔
166
            $versionRange = Enum\VersionRange::tryFromInput($rangeOrVersion) ?? $rangeOrVersion;
17✔
167
        } elseif ([] !== $config->versionRangeIndicators()) {
4✔
168
            $versionRange = $this->versionRangeDetector->detect($rootPath, $config->versionRangeIndicators());
3✔
169
        } else {
170
            $this->io->error('Please provide a version range or explicit version to bump in configured files.');
1✔
171
            $this->io->block(
1✔
172
                'You can also enable auto-detection by adding version range indicators to your configuration file.',
1✔
173
                null,
1✔
174
                'fg=cyan',
1✔
175
                '💡 ',
1✔
176
            );
1✔
177

178
            return null;
1✔
179
        }
180

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

185
            return null;
1✔
186
        }
187

188
        return $versionRange;
18✔
189
    }
190

191
    /**
192
     * @return list<Result\VersionBumpResult>|null
193
     *
194
     * @throws Throwable
195
     */
196
    private function bumpVersions(
18✔
197
        Config\VersionBumperConfig $config,
198
        Enum\VersionRange|string $versionRange,
199
        string $rootPath,
200
        bool $dryRun,
201
    ): ?array {
202
        $results = [];
18✔
203

204
        $this->decorateAppliedPresets($config->presets());
18✔
205

206
        if ($this->io->isVerbose()) {
18✔
207
            $this->io->title('Running version bumper');
3✔
208
        }
209

210
        // Execute pre-actions
211
        if (!$dryRun && !$this->executeActions($config, Version\Action\ActionType::PreAction, $results, $rootPath)) {
18✔
212
            return null;
2✔
213
        }
214

215
        // Bump versions
216
        $versionBumpResults = $this->taskRunner->run(
16✔
217
            'Bumping versions in files',
16✔
218
            function (TaskRunner\RunnerContext $context) use ($config, $rootPath, $versionRange, $dryRun) {
16✔
219
                $results = $this->bumper->bump($config->filesToModify(), $rootPath, $versionRange, $dryRun);
16✔
220

221
                if ([] === $results) {
12✔
222
                    $context->statusMessage = '<comment>Skipped</comment>';
1✔
223
                }
224

225
                return $results;
12✔
226
            },
16✔
227
        );
16✔
228

229
        // Merged results from version bump with global results
230
        $this->mergeResults($versionBumpResults, $results, $rootPath);
12✔
231

232
        // Execute post-actions
233
        if (!$dryRun && !$this->executeActions($config, Version\Action\ActionType::PostAction, $results, $rootPath)) {
12✔
234
            return null;
1✔
235
        }
236

237
        return $results;
11✔
238
    }
239

240
    /**
241
     * @param list<Result\VersionBumpResult> $results
242
     */
243
    private function executeActions(
9✔
244
        Config\VersionBumperConfig $config,
245
        Version\Action\ActionType $type,
246
        array &$results,
247
        string $rootPath,
248
    ): bool {
249
        if (!$config->hasActions($type)) {
9✔
250
            return true;
7✔
251
        }
252

253
        try {
254
            return $this->taskRunner->run(
7✔
255
                sprintf('Executing %s', $type->label(true)),
7✔
256
                function (TaskRunner\RunnerContext $context) use ($config, &$results, $rootPath, $type): bool {
7✔
257
                    $dispatcher = new Version\ActionDispatcher($rootPath, $this->io);
7✔
258

259
                    // Consider only files with matched operations for post-actions,
260
                    // otherwise take all configured files into account
261
                    if (Version\Action\ActionType::PostAction === $type) {
7✔
262
                        $filesToConsider = array_map(
4✔
263
                            static fn (Result\VersionBumpResult $result) => $result->file(),
4✔
264
                            array_filter(
4✔
265
                                $results,
4✔
266
                                static fn (Result\VersionBumpResult $result) => $result->hasMatchedOperations(),
4✔
267
                            ),
4✔
268
                        );
4✔
269
                    } else {
270
                        $filesToConsider = $config->filesToModify();
3✔
271
                    }
272

273
                    foreach ($filesToConsider as $fileToModify) {
7✔
274
                        $actionExecutionResult = $dispatcher->dispatchAll(
7✔
275
                            $fileToModify->getActionsByType($type),
7✔
276
                            $fileToModify,
7✔
277
                        );
7✔
278

279
                        if ($actionExecutionResult->failed()) {
7✔
280
                            if ($context->output->isVerbose() && $actionExecutionResult->hasOutput()) {
3✔
281
                                $context->output->write($actionExecutionResult->output());
1✔
282
                            }
283

284
                            throw new Exception\ActionExecutionFailed($actionExecutionResult);
3✔
285
                        }
286

287
                        $this->mergeResults($actionExecutionResult->results(), $results, $rootPath);
4✔
288
                    }
289

290
                    return true;
4✔
291
                },
7✔
292
            );
7✔
293
        } catch (Exception\ActionExecutionFailed) {
3✔
294
            $this->io->error(
3✔
295
                sprintf('An error occured while executing %s.', $type->label(true)),
3✔
296
            );
3✔
297

298
            return false;
3✔
299
        } catch (Throwable $exception) {
×
300
            $this->io->error($exception->getMessage());
×
301

302
            return false;
×
303
        }
304
    }
305

306
    /**
307
     * @param list<Result\VersionBumpResult> $results
308
     *
309
     * @throws Exception\Exception
310
     */
311
    private function releaseVersion(
2✔
312
        array $results,
313
        string $rootPath,
314
        Config\ReleaseOptions $options,
315
        Enum\VersionRange|string $versionRange,
316
        bool $dryRun,
317
    ): bool {
318
        $this->io->title('Release');
2✔
319

320
        try {
321
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $versionRange, $dryRun);
2✔
322

323
            $this->decorateVersionReleaseResult($releaseResult);
1✔
324

325
            return true;
1✔
326
        } catch (Exception\Exception $exception) {
1✔
327
            throw $exception;
1✔
328
        } catch (\Exception $exception) {
×
329
            $this->io->error('Git error during release: '.$exception->getMessage());
×
330
        }
331

332
        return false;
×
333
    }
334

335
    /**
336
     * @param list<Result\VersionBumpResult> $source
337
     * @param list<Result\VersionBumpResult> $target
338
     */
339
    private function mergeResults(array $source, array &$target, string $rootPath): void
12✔
340
    {
341
        foreach ($source as $sourceResult) {
12✔
342
            $finalResult = null;
11✔
343

344
            foreach ($target as $targetResult) {
11✔
345
                if ($targetResult->file()->equals($sourceResult->file(), $rootPath)) {
8✔
346
                    $finalResult = $targetResult;
1✔
347
                    break;
1✔
348
                }
349
            }
350

351
            if (null !== $finalResult) {
11✔
352
                $finalResult->merge($sourceResult);
1✔
353
            } else {
354
                $target[] = $sourceResult;
11✔
355
            }
356
        }
357
    }
358

359
    /**
360
     * @param list<Config\Preset\Preset> $presets
361
     */
362
    private function decorateAppliedPresets(array $presets): void
18✔
363
    {
364
        if ([] === $presets || !$this->io->isVerbose()) {
18✔
365
            return;
17✔
366
        }
367

368
        $this->io->title('Applied presets');
1✔
369

370
        $this->io->listing(
1✔
371
            array_map(
1✔
372
                static fn (Config\Preset\Preset $preset) => sprintf(
1✔
373
                    '%s <fg=gray>(%s)</>',
1✔
374
                    $preset::getDescription(),
1✔
375
                    $preset::getIdentifier(),
1✔
376
                ),
1✔
377
                $presets,
1✔
378
            ),
1✔
379
        );
1✔
380
    }
381

382
    /**
383
     * @param list<Result\VersionBumpResult> $results
384
     */
385
    private function decorateVersionBumpResults(array $results, string $rootPath): void
11✔
386
    {
387
        $titleDisplayed = false;
11✔
388

389
        usort(
11✔
390
            $results,
11✔
391
            static fn (
11✔
392
                Result\VersionBumpResult $a,
11✔
393
                Result\VersionBumpResult $b,
11✔
394
            ) => $a->file()->fullPath($rootPath) <=> $b->file()->fullPath($rootPath),
8✔
395
        );
11✔
396

397
        foreach ($results as $result) {
11✔
398
            if (!$result->hasOperations()) {
10✔
399
                continue;
4✔
400
            }
401

402
            $path = $result->file()->path();
10✔
403
            $groupedOperations = $result->groupedOperations();
10✔
404
            $hasOnlySkippedOperations = [] === array_filter(
10✔
405
                $groupedOperations,
10✔
406
                static fn (array $operations) => Enum\OperationState::Skipped !== reset($operations)->state(),
10✔
407
            );
10✔
408

409
            if (Filesystem\Path::isAbsolute($path)) {
10✔
410
                $path = Filesystem\Path::makeRelative($path, $rootPath);
3✔
411
            }
412

413
            if (!$titleDisplayed) {
10✔
414
                $this->io->title('Bumped versions');
10✔
415
                $titleDisplayed = true;
10✔
416
            }
417

418
            $this->io->section($path);
10✔
419

420
            foreach ($groupedOperations as $operations) {
10✔
421
                $operation = reset($operations);
10✔
422
                $numberOfOperations = count($operations);
10✔
423
                $state = $operation->state();
10✔
424

425
                if (Enum\OperationState::Skipped === $state && !$hasOnlySkippedOperations) {
10✔
426
                    continue;
3✔
427
                }
428

429
                $message = match ($state) {
10✔
430
                    Enum\OperationState::Modified => sprintf(
10✔
431
                        '✅ Bumped version from "%s" to "%s"',
10✔
432
                        $operation->source()?->full() ?? '',
10✔
433
                        $operation->target()?->full() ?? '',
10✔
434
                    ),
10✔
435
                    Enum\OperationState::Regenerated => '🔁 Regenerated lock file (via post-action)',
8✔
436
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
437
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()?->original(),
4✔
438
                };
10✔
439

440
                if ($numberOfOperations > 1) {
10✔
441
                    $message .= sprintf(' (%dx)', $numberOfOperations);
3✔
442
                }
443

444
                $this->io->writeln($message);
10✔
445
            }
446
        }
447
    }
448

449
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
450
    {
451
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
452
        $releaseInformation = [];
1✔
453

454
        if ($numberOfCommittedFiles > 0) {
1✔
455
            $releaseInformation[] = sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : '');
1✔
456
        }
457

458
        if (null !== $result->commitMessage()) {
1✔
459
            $releaseInformation[] = sprintf('Committed: <info>%s</info>', $result->commitMessage());
1✔
460
        }
461

462
        if (null !== $result->commitId()) {
1✔
463
            $releaseInformation[] = sprintf('Commit hash: <info>%s</info>', $result->commitId());
×
464
        }
465

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

468
        $this->io->listing($releaseInformation);
1✔
469
    }
470
}
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