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

eliashaeussler / version-bumper / 24195454818

09 Apr 2026 02:24PM UTC coverage: 88.488% (-0.09%) from 88.577%
24195454818

push

github

web-flow
Merge pull request #123 from eliashaeussler/fix/root-path

33 of 33 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

884 of 999 relevant lines covered (88.49%)

4.89 hits per line

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

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

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

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

64
    public function __construct(
14✔
65
        ?Composer $composer = null,
66
        ?Caller\CallerInterface $caller = null,
67
    ) {
68
        if (null !== $composer) {
14✔
69
            $this->setComposer($composer);
1✔
70
        }
71

72
        parent::__construct('bump-version');
14✔
73

74
        $this->bumper = new Version\VersionBumper();
14✔
75
        $this->configReader = new Config\ConfigReader();
14✔
76
        $this->versionRangeDetector = new Version\VersionRangeDetector($caller);
14✔
77
        $this->releaser = new Version\VersionReleaser($caller);
14✔
78
    }
79

80
    protected function configure(): void
14✔
81
    {
82
        $this->setAliases(['bv']);
14✔
83
        $this->setDescription('Bump package version in specific files during release preparations');
14✔
84

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

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

121
    protected function initialize(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): void
14✔
122
    {
123
        $this->io = new Console\Style\SymfonyStyle($input, $output);
14✔
124
    }
125

126
    protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
14✔
127
    {
128
        $rootPath = (string) getcwd();
14✔
129
        $rangeOrVersion = $input->getArgument('range');
14✔
130
        $configFile = $input->getOption('config') ?? $this->configReader->detectFile($rootPath);
14✔
131
        $release = $input->getOption('release');
14✔
132
        $dryRun = $input->getOption('dry-run');
14✔
133
        $strict = $input->getOption('strict');
14✔
134

135
        if (null === $configFile) {
14✔
136
            $this->io->error('Please provide a config file path using the --config option.');
1✔
137

138
            return self::INVALID;
1✔
139
        }
140

141
        if (Filesystem\Path::isRelative($configFile)) {
13✔
142
            $configFile = Filesystem\Path::makeAbsolute($configFile, $rootPath);
1✔
143
        } else {
144
            $rootPath = dirname($configFile);
12✔
145
        }
146

147
        try {
148
            $config = $this->configReader->readFromFile($configFile);
13✔
149

150
            // Override root path from config file
151
            if (null !== $config->rootPath()) {
12✔
152
                $rootPath = $config->rootPath();
12✔
153
            }
154

155
            $results = $this->bumpVersions($config, $rangeOrVersion, $rootPath, $dryRun);
12✔
156

157
            if (null === $results) {
7✔
158
                return self::FAILURE;
2✔
159
            }
160

161
            if ($release && !$this->releaseVersion($results, $rootPath, $config->releaseOptions(), $dryRun)) {
5✔
162
                return self::FAILURE;
4✔
163
            }
164
        } catch (Valinor\Mapper\MappingError $error) {
7✔
165
            $this->decorateMappingError($error, $configFile);
1✔
166

167
            return self::FAILURE;
1✔
168
        } catch (Exception\Exception $exception) {
6✔
169
            $this->io->error($exception->getMessage());
6✔
170

171
            return self::FAILURE;
6✔
172
        } finally {
173
            if ($dryRun) {
13✔
174
                $this->io->note('No write operations were performed (dry-run mode).');
7✔
175
            }
176
        }
177

178
        if ($strict) {
4✔
179
            foreach ($results as $versionBumpResult) {
1✔
180
                if ($versionBumpResult->hasUnmatchedReports()) {
1✔
181
                    return self::FAILURE;
1✔
182
                }
183
            }
184
        }
185

186
        return self::SUCCESS;
3✔
187
    }
188

189
    /**
190
     * @return list<Result\VersionBumpResult>|null
191
     *
192
     * @throws Exception\Exception
193
     */
194
    private function bumpVersions(
12✔
195
        Config\VersionBumperConfig $config,
196
        ?string $rangeOrVersion,
197
        string $rootPath,
198
        bool $dryRun,
199
    ): ?array {
200
        // Auto-detect version range from indicators
201
        if (null !== $rangeOrVersion) {
12✔
202
            $versionRange = Enum\VersionRange::tryFromInput($rangeOrVersion) ?? $rangeOrVersion;
8✔
203
        } elseif ([] !== $config->versionRangeIndicators()) {
4✔
204
            $versionRange = $this->versionRangeDetector->detect($rootPath, $config->versionRangeIndicators());
3✔
205
        } else {
206
            $this->io->error('Please provide a version range or explicit version to bump in configured files.');
1✔
207
            $this->io->block(
1✔
208
                'You can also enable auto-detection by adding version range indicators to your configuration file.',
1✔
209
                null,
1✔
210
                'fg=cyan',
1✔
211
                '💡 ',
1✔
212
            );
1✔
213

214
            return null;
1✔
215
        }
216

217
        // Exit early if version range detection fails
218
        if (null === $versionRange) {
10✔
219
            $this->io->error('Unable to auto-detect version range. Please provide a version range or explicit version instead.');
1✔
220

221
            return null;
1✔
222
        }
223

224
        $this->decorateAppliedPresets($config->presets());
9✔
225

226
        $results = $this->bumper->bump($config->filesToModify(), $rootPath, $versionRange, $dryRun);
9✔
227

228
        $this->decorateVersionBumpResults($results, $rootPath);
5✔
229

230
        return $results;
5✔
231
    }
232

233
    /**
234
     * @param list<Result\VersionBumpResult> $results
235
     *
236
     * @throws Exception\Exception
237
     */
238
    private function releaseVersion(array $results, string $rootPath, Config\ReleaseOptions $options, bool $dryRun): bool
2✔
239
    {
240
        $this->io->title('Release');
2✔
241

242
        try {
243
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $dryRun);
2✔
244

245
            $this->decorateVersionReleaseResult($releaseResult);
1✔
246

247
            return true;
1✔
248
        } catch (Exception\Exception $exception) {
1✔
249
            throw $exception;
1✔
250
        } catch (\Exception $exception) {
×
251
            $this->io->error('Git error during release: '.$exception->getMessage());
×
252
        }
253

UNCOV
254
        return false;
×
255
    }
256

257
    /**
258
     * @param list<Config\Preset\Preset> $presets
259
     */
260
    private function decorateAppliedPresets(array $presets): void
9✔
261
    {
262
        if ([] === $presets || !$this->io->isVerbose()) {
9✔
263
            return;
8✔
264
        }
265

266
        $this->io->title('Applied presets');
1✔
267

268
        $this->io->listing(
1✔
269
            array_map(
1✔
270
                static fn (Config\Preset\Preset $preset) => sprintf(
1✔
271
                    '%s <fg=gray>(%s)</>',
1✔
272
                    $preset::getDescription(),
1✔
273
                    $preset::getIdentifier(),
1✔
274
                ),
1✔
275
                $presets,
1✔
276
            ),
1✔
277
        );
1✔
278
    }
279

280
    /**
281
     * @param list<Result\VersionBumpResult> $results
282
     */
283
    private function decorateVersionBumpResults(array $results, string $rootPath): void
5✔
284
    {
285
        $titleDisplayed = false;
5✔
286

287
        foreach ($results as $result) {
5✔
288
            if (!$result->hasOperations()) {
5✔
289
                continue;
4✔
290
            }
291

292
            $path = $result->file()->path();
5✔
293
            $groupedOperations = $result->groupedOperations();
5✔
294
            $hasOnlySkippedOperations = [] === array_filter(
5✔
295
                $groupedOperations,
5✔
296
                static fn (array $operations) => Enum\OperationState::Skipped !== reset($operations)->state(),
5✔
297
            );
5✔
298

299
            if (Filesystem\Path::isAbsolute($path)) {
5✔
300
                $path = Filesystem\Path::makeRelative($path, $rootPath);
×
301
            }
302

303
            if (!$titleDisplayed) {
5✔
304
                $this->io->title('Bumped versions');
5✔
305
                $titleDisplayed = true;
5✔
306
            }
307

308
            $this->io->section($path);
5✔
309

310
            foreach ($groupedOperations as $operations) {
5✔
311
                $operation = reset($operations);
5✔
312
                $numberOfOperations = count($operations);
5✔
313
                $state = $operation->state();
5✔
314

315
                if (Enum\OperationState::Skipped === $state && !$hasOnlySkippedOperations) {
5✔
316
                    continue;
3✔
317
                }
318

319
                $message = match ($state) {
5✔
320
                    Enum\OperationState::Modified => sprintf(
5✔
321
                        '✅ Bumped version from "%s" to "%s"',
5✔
322
                        $operation->source()?->full() ?? '',
5✔
323
                        $operation->target()?->full() ?? '',
5✔
324
                    ),
5✔
325
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
326
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()->original(),
4✔
327
                };
5✔
328

329
                if ($numberOfOperations > 1) {
5✔
330
                    $message .= sprintf(' (%dx)', $numberOfOperations);
3✔
331
                }
332

333
                $this->io->writeln($message);
5✔
334
            }
335
        }
336
    }
337

338
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
339
    {
340
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
341
        $releaseInformation = [
1✔
342
            sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : ''),
1✔
343
            sprintf('Committed: <info>%s</info>', $result->commitMessage()),
1✔
344
        ];
1✔
345

346
        if (null !== $result->commitId()) {
1✔
347
            $releaseInformation[] = sprintf('Commit hash: %s', $result->commitId());
×
348
        }
349

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

352
        $this->io->listing($releaseInformation);
1✔
353
    }
354

355
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
1✔
356
    {
357
        $errorMessages = [];
1✔
358
        $errors = $error->messages()->errors();
1✔
359

360
        $this->io->error(
1✔
361
            sprintf('The config file "%s" is invalid.', $configFile),
1✔
362
        );
1✔
363

364
        foreach ($errors as $propertyError) {
1✔
365
            $errorMessages[] = sprintf('%s: %s', $propertyError->path(), $propertyError->toString());
1✔
366
        }
367

368
        $this->io->listing($errorMessages);
1✔
369
    }
370

371
    private function readConfigFileFromRootPackage(): ?string
14✔
372
    {
373
        if (method_exists($this, 'tryComposer')) {
14✔
374
            // Composer >= 2.3
375
            $composer = $this->tryComposer();
14✔
376
        } else {
377
            // Composer < 2.3
378
            $composer = $this->getComposer(false);
×
379
        }
380

381
        if (null === $composer) {
14✔
382
            return null;
14✔
383
        }
384

385
        $extra = $composer->getPackage()->getExtra();
1✔
386
        /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
387
        $configFile = $extra['version-bumper']['config-file'] ?? null;
1✔
388

389
        if (is_string($configFile) && '' !== trim($configFile)) {
1✔
390
            return $configFile;
1✔
391
        }
392

393
        return null;
×
394
    }
395
}
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