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

eliashaeussler / version-bumper / 24197255955

09 Apr 2026 03:00PM UTC coverage: 87.916% (-0.6%) from 88.488%
24197255955

push

github

web-flow
Merge pull request #122 from eliashaeussler/fix/update-lock

42 of 55 new or added lines in 1 file covered. (76.36%)

924 of 1051 relevant lines covered (87.92%)

4.85 hits per line

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

92.12
/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 Composer\Console\Application;
29
use Composer\Factory;
30
use CuyZ\Valinor;
31
use EliasHaeussler\VersionBumper\Config;
32
use EliasHaeussler\VersionBumper\Enum;
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

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

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

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

74
        parent::__construct('bump-version');
15✔
75

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

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

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

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

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

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

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

140
            return self::INVALID;
1✔
141
        }
142

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

149
        try {
150
            $config = $this->configReader->readFromFile($configFile);
14✔
151

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

157
            $results = $this->bumpVersions($config, $rangeOrVersion, $rootPath, $dryRun);
13✔
158

159
            if (null === $results) {
8✔
160
                return self::FAILURE;
2✔
161
            }
162

163
            if (!$this->updateComposerLockIfNeeded($input, $output, $rootPath, $results)) {
6✔
NEW
164
                return self::FAILURE;
×
165
            }
166

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

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

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

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

192
        return self::SUCCESS;
4✔
193
    }
194

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

220
            return null;
1✔
221
        }
222

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

227
            return null;
1✔
228
        }
229

230
        $this->decorateAppliedPresets($config->presets());
10✔
231

232
        $results = $this->bumper->bump($config->filesToModify(), $rootPath, $versionRange, $dryRun);
10✔
233

234
        $this->decorateVersionBumpResults($results, $rootPath);
6✔
235

236
        return $results;
6✔
237
    }
238

239
    /**
240
     * @param list<Result\VersionBumpResult> $results
241
     *
242
     * @throws Exception\Exception
243
     */
244
    private function releaseVersion(array $results, string $rootPath, Config\ReleaseOptions $options, bool $dryRun): bool
2✔
245
    {
246
        $this->io->title('Release');
2✔
247

248
        try {
249
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $dryRun);
2✔
250

251
            $this->decorateVersionReleaseResult($releaseResult);
1✔
252

253
            return true;
1✔
254
        } catch (Exception\Exception $exception) {
1✔
255
            throw $exception;
1✔
256
        } catch (\Exception $exception) {
×
257
            $this->io->error('Git error during release: '.$exception->getMessage());
×
258
        }
259

260
        return false;
×
261
    }
262

263
    /**
264
     * @param list<Config\Preset\Preset> $presets
265
     */
266
    private function decorateAppliedPresets(array $presets): void
10✔
267
    {
268
        if ([] === $presets || !$this->io->isVerbose()) {
10✔
269
            return;
9✔
270
        }
271

272
        $this->io->title('Applied presets');
1✔
273

274
        $this->io->listing(
1✔
275
            array_map(
1✔
276
                static fn (Config\Preset\Preset $preset) => sprintf(
1✔
277
                    '%s <fg=gray>(%s)</>',
1✔
278
                    $preset::getDescription(),
1✔
279
                    $preset::getIdentifier(),
1✔
280
                ),
1✔
281
                $presets,
1✔
282
            ),
1✔
283
        );
1✔
284
    }
285

286
    /**
287
     * @param list<Result\VersionBumpResult> $results
288
     */
289
    private function decorateVersionBumpResults(array $results, string $rootPath): void
6✔
290
    {
291
        $titleDisplayed = false;
6✔
292

293
        foreach ($results as $result) {
6✔
294
            if (!$result->hasOperations()) {
6✔
295
                continue;
4✔
296
            }
297

298
            $path = $result->file()->path();
6✔
299
            $groupedOperations = $result->groupedOperations();
6✔
300
            $hasOnlySkippedOperations = [] === array_filter(
6✔
301
                $groupedOperations,
6✔
302
                static fn (array $operations) => Enum\OperationState::Skipped !== reset($operations)->state(),
6✔
303
            );
6✔
304

305
            if (Filesystem\Path::isAbsolute($path)) {
6✔
306
                $path = Filesystem\Path::makeRelative($path, $rootPath);
×
307
            }
308

309
            if (!$titleDisplayed) {
6✔
310
                $this->io->title('Bumped versions');
6✔
311
                $titleDisplayed = true;
6✔
312
            }
313

314
            $this->io->section($path);
6✔
315

316
            foreach ($groupedOperations as $operations) {
6✔
317
                $operation = reset($operations);
6✔
318
                $numberOfOperations = count($operations);
6✔
319
                $state = $operation->state();
6✔
320

321
                if (Enum\OperationState::Skipped === $state && !$hasOnlySkippedOperations) {
6✔
322
                    continue;
3✔
323
                }
324

325
                $message = match ($state) {
6✔
326
                    Enum\OperationState::Modified => sprintf(
6✔
327
                        '✅ Bumped version from "%s" to "%s"',
6✔
328
                        $operation->source()?->full() ?? '',
6✔
329
                        $operation->target()?->full() ?? '',
6✔
330
                    ),
6✔
331
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
332
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()->original(),
4✔
333
                };
6✔
334

335
                if ($numberOfOperations > 1) {
6✔
336
                    $message .= sprintf(' (%dx)', $numberOfOperations);
3✔
337
                }
338

339
                $this->io->writeln($message);
6✔
340
            }
341
        }
342
    }
343

344
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
345
    {
346
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
347
        $releaseInformation = [
1✔
348
            sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : ''),
1✔
349
            sprintf('Committed: <info>%s</info>', $result->commitMessage()),
1✔
350
        ];
1✔
351

352
        if (null !== $result->commitId()) {
1✔
353
            $releaseInformation[] = sprintf('Commit hash: %s', $result->commitId());
×
354
        }
355

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

358
        $this->io->listing($releaseInformation);
1✔
359
    }
360

361
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
1✔
362
    {
363
        $errorMessages = [];
1✔
364
        $errors = $error->messages()->errors();
1✔
365

366
        $this->io->error(
1✔
367
            sprintf('The config file "%s" is invalid.', $configFile),
1✔
368
        );
1✔
369

370
        foreach ($errors as $propertyError) {
1✔
371
            $errorMessages[] = sprintf('%s: %s', $propertyError->path(), $propertyError->toString());
1✔
372
        }
373

374
        $this->io->listing($errorMessages);
1✔
375
    }
376

377
    private function readConfigFileFromRootPackage(): ?string
15✔
378
    {
379
        $composer = $this->getComposerInstance();
15✔
380

381
        if (null === $composer) {
15✔
382
            return null;
15✔
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

396
    /**
397
     * @param list<Result\VersionBumpResult> $results
398
     */
399
    private function updateComposerLockIfNeeded(
6✔
400
        Console\Input\InputInterface $input,
401
        Console\Output\OutputInterface $output,
402
        string $rootPath,
403
        array &$results,
404
    ): bool {
405
        $this->resetComposer();
6✔
406

407
        $composer = $this->getComposerInstance();
6✔
408
        $locker = $composer?->getLocker();
6✔
409

410
        if (null === $locker || !$locker->isLocked() || $locker->isFresh()) {
6✔
411
            return true;
5✔
412
        }
413

414
        $this->io->title('File integrity');
1✔
415

416
        $application = new Application();
1✔
417
        $application->setAutoExit(false);
1✔
418

419
        $commandOutput = new Console\Output\BufferedOutput(
1✔
420
            $output->getVerbosity(),
1✔
421
            $output->isDecorated(),
1✔
422
            $output->getFormatter(),
1✔
423
        );
1✔
424

425
        $parameters = [
1✔
426
            'command' => 'update',
1✔
427
            '--ignore-platform-reqs' => true,
1✔
428
            '--lock' => true,
1✔
429
            '--no-autoloader' => true,
1✔
430
            '--working-dir' => $rootPath,
1✔
431
        ];
1✔
432

433
        if ($input->hasParameterOption('--no-ansi')) {
1✔
NEW
434
            $parameters['--no-ansi'] = $input->getParameterOption('--no-ansi');
×
435
        }
436

437
        if ($input->hasParameterOption('--no-plugins')) {
1✔
NEW
438
            $parameters['--no-plugins'] = $input->getParameterOption('--no-plugins');
×
439
        }
440

441
        if ($input->hasParameterOption('--no-scripts')) {
1✔
NEW
442
            $parameters['--no-scripts'] = $input->getParameterOption('--no-scripts');
×
443
        }
444

445
        try {
446
            $result = $application->run(new Console\Input\ArrayInput($parameters), $commandOutput);
1✔
NEW
447
        } catch (\Exception $exception) {
×
NEW
448
            $this->io->write($commandOutput->fetch());
×
NEW
449
            $this->io->error($exception->getMessage());
×
450

NEW
451
            return false;
×
452
        }
453

454
        if (self::SUCCESS === $result) {
1✔
455
            $this->io->writeln('✅ Updated <info>composer.lock</info> file (content hash change)');
1✔
456

457
            $composerJson = $composer->getConfig()->getConfigSource()->getName();
1✔
458
            $composerLock = Factory::getLockFile($composerJson);
1✔
459

460
            // Add modified lock file to results array to include it in subsequent release operations
461
            foreach ($results as $result) {
1✔
462
                if ($result->file()->fullPath($rootPath) === $composerJson) {
1✔
463
                    $results[] = new Result\VersionBumpResult(
1✔
464
                        new Config\FileToModify($composerLock),
1✔
465
                        $result->operations(),
1✔
466
                    );
1✔
467

468
                    break;
1✔
469
                }
470
            }
471

472
            return true;
1✔
473
        }
474

NEW
475
        if ($output->isVerbose()) {
×
NEW
476
            $this->io->write($commandOutput->fetch());
×
477
        }
478

NEW
479
        $this->io->error('An error occurred while updating the composer.lock file.');
×
480

NEW
481
        return false;
×
482
    }
483

484
    private function getComposerInstance(): ?Composer
15✔
485
    {
486
        // Composer >= 2.3
487
        if (method_exists($this, 'tryComposer')) {
15✔
488
            return $this->tryComposer();
15✔
489
        }
490

491
        // Composer < 2.3
NEW
492
        return $this->getComposer(false);
×
493
    }
494
}
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