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

eliashaeussler / version-bumper / 24155816115

08 Apr 2026 08:00PM UTC coverage: 85.99% (-2.6%) from 88.577%
24155816115

Pull #122

github

eliashaeussler
[BUGFIX] Update composer.lock file after composer.json has changed
Pull Request #122: [BUGFIX] Update composer.lock file after composer.json has changed

9 of 41 new or added lines in 1 file covered. (21.95%)

8 existing lines in 1 file now uncovered.

890 of 1035 relevant lines covered (85.99%)

4.76 hits per line

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

83.56
/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 CuyZ\Valinor;
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

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

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

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

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

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

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

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

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

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

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

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

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

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

148
        try {
149
            $result = $this->bumpVersions($configFile, $rangeOrVersion, $rootPath, $dryRun);
13✔
150

151
            if (null === $result) {
13✔
152
                return self::FAILURE;
8✔
153
            }
154

155
            [$config, $results] = $result;
5✔
156

157
            if ($release && !$this->releaseVersion($results, $rootPath, $config->releaseOptions(), $dryRun)) {
5✔
158
                return self::FAILURE;
1✔
159
            }
160
        } finally {
161
            if ($dryRun) {
13✔
162
                $this->io->note('No write operations were performed (dry-run mode).');
7✔
163
            }
164
        }
165

166
        if ($strict) {
4✔
167
            foreach ($results as $versionBumpResult) {
1✔
168
                if ($versionBumpResult->hasUnmatchedReports()) {
1✔
169
                    return self::FAILURE;
1✔
170
                }
171
            }
172
        }
173

174
        return $this->updateComposerLockIfNeeded($input, $output, $rootPath);
3✔
175
    }
176

177
    /**
178
     * @return array{Config\VersionBumperConfig, list<Result\VersionBumpResult>}|null
179
     */
180
    private function bumpVersions(string $configFile, ?string $rangeOrVersion, string $rootPath, bool $dryRun): ?array
13✔
181
    {
182
        try {
183
            $config = $this->configReader->readFromFile($configFile);
13✔
184
            $finalRootPath = $config->rootPath() ?? $rootPath;
12✔
185

186
            // Auto-detect version range from indicators
187
            if (null !== $rangeOrVersion) {
12✔
188
                $versionRange = Enum\VersionRange::tryFromInput($rangeOrVersion) ?? $rangeOrVersion;
8✔
189
            } elseif ([] !== $config->versionRangeIndicators()) {
4✔
190
                $versionRange = $this->versionRangeDetector->detect($finalRootPath, $config->versionRangeIndicators());
3✔
191
            } else {
192
                $this->io->error('Please provide a version range or explicit version to bump in configured files.');
1✔
193
                $this->io->block(
1✔
194
                    'You can also enable auto-detection by adding version range indicators to your configuration file.',
1✔
195
                    null,
1✔
196
                    'fg=cyan',
1✔
197
                    '💡 ',
1✔
198
                );
1✔
199

200
                return null;
1✔
201
            }
202

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

207
                return null;
1✔
208
            }
209

210
            $this->decorateAppliedPresets($config->presets());
9✔
211

212
            $results = $this->bumper->bump($config->filesToModify(), $finalRootPath, $versionRange, $dryRun);
9✔
213

214
            $this->decorateVersionBumpResults($results, $rootPath);
5✔
215

216
            return [$config, $results];
5✔
217
        } catch (Valinor\Mapper\MappingError $error) {
6✔
218
            $this->decorateMappingError($error, $configFile);
1✔
219
        } catch (Exception\Exception $exception) {
5✔
220
            $this->io->error($exception->getMessage());
5✔
221
        }
222

223
        return null;
6✔
224
    }
225

226
    /**
227
     * @param list<Result\VersionBumpResult> $results
228
     */
229
    private function releaseVersion(array $results, string $rootPath, Config\ReleaseOptions $options, bool $dryRun): bool
2✔
230
    {
231
        $this->io->title('Release');
2✔
232

233
        try {
234
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $dryRun);
2✔
235

236
            $this->decorateVersionReleaseResult($releaseResult);
1✔
237

238
            return true;
1✔
239
        } catch (Exception\Exception $exception) {
1✔
240
            $this->io->error($exception->getMessage());
1✔
UNCOV
241
        } catch (\Exception $exception) {
×
UNCOV
242
            $this->io->error('Git error during release: '.$exception->getMessage());
×
243
        }
244

245
        return false;
1✔
246
    }
247

248
    /**
249
     * @param list<Config\Preset\Preset> $presets
250
     */
251
    private function decorateAppliedPresets(array $presets): void
9✔
252
    {
253
        if ([] === $presets || !$this->io->isVerbose()) {
9✔
254
            return;
8✔
255
        }
256

257
        $this->io->title('Applied presets');
1✔
258

259
        $this->io->listing(
1✔
260
            array_map(
1✔
261
                static fn (Config\Preset\Preset $preset) => sprintf(
1✔
262
                    '%s <fg=gray>(%s)</>',
1✔
263
                    $preset::getDescription(),
1✔
264
                    $preset::getIdentifier(),
1✔
265
                ),
1✔
266
                $presets,
1✔
267
            ),
1✔
268
        );
1✔
269
    }
270

271
    /**
272
     * @param list<Result\VersionBumpResult> $results
273
     */
274
    private function decorateVersionBumpResults(array $results, string $rootPath): void
5✔
275
    {
276
        $titleDisplayed = false;
5✔
277

278
        foreach ($results as $result) {
5✔
279
            if (!$result->hasOperations()) {
5✔
280
                continue;
4✔
281
            }
282

283
            $path = $result->file()->path();
5✔
284
            $groupedOperations = $result->groupedOperations();
5✔
285
            $hasOnlySkippedOperations = [] === array_filter(
5✔
286
                $groupedOperations,
5✔
287
                static fn (array $operations) => Enum\OperationState::Skipped !== reset($operations)->state(),
5✔
288
            );
5✔
289

290
            if (Filesystem\Path::isAbsolute($path)) {
5✔
UNCOV
291
                $path = Filesystem\Path::makeRelative($path, $rootPath);
×
292
            }
293

294
            if (!$titleDisplayed) {
5✔
295
                $this->io->title('Bumped versions');
5✔
296
                $titleDisplayed = true;
5✔
297
            }
298

299
            $this->io->section($path);
5✔
300

301
            foreach ($groupedOperations as $operations) {
5✔
302
                $operation = reset($operations);
5✔
303
                $numberOfOperations = count($operations);
5✔
304
                $state = $operation->state();
5✔
305

306
                if (Enum\OperationState::Skipped === $state && !$hasOnlySkippedOperations) {
5✔
307
                    continue;
3✔
308
                }
309

310
                $message = match ($state) {
5✔
311
                    Enum\OperationState::Modified => sprintf(
5✔
312
                        '✅ Bumped version from "%s" to "%s"',
5✔
313
                        $operation->source()?->full() ?? '',
5✔
314
                        $operation->target()?->full() ?? '',
5✔
315
                    ),
5✔
316
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
317
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()->original(),
4✔
318
                };
5✔
319

320
                if ($numberOfOperations > 1) {
5✔
321
                    $message .= sprintf(' (%dx)', $numberOfOperations);
3✔
322
                }
323

324
                $this->io->writeln($message);
5✔
325
            }
326
        }
327
    }
328

329
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
330
    {
331
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
332
        $releaseInformation = [
1✔
333
            sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : ''),
1✔
334
            sprintf('Committed: <info>%s</info>', $result->commitMessage()),
1✔
335
        ];
1✔
336

337
        if (null !== $result->commitId()) {
1✔
UNCOV
338
            $releaseInformation[] = sprintf('Commit hash: %s', $result->commitId());
×
339
        }
340

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

343
        $this->io->listing($releaseInformation);
1✔
344
    }
345

346
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
1✔
347
    {
348
        $errorMessages = [];
1✔
349
        $errors = $error->messages()->errors();
1✔
350

351
        $this->io->error(
1✔
352
            sprintf('The config file "%s" is invalid.', $configFile),
1✔
353
        );
1✔
354

355
        foreach ($errors as $propertyError) {
1✔
356
            $errorMessages[] = sprintf('%s: %s', $propertyError->path(), $propertyError->toString());
1✔
357
        }
358

359
        $this->io->listing($errorMessages);
1✔
360
    }
361

362
    private function readConfigFileFromRootPackage(): ?string
14✔
363
    {
364
        $composer = $this->getComposerInstance();
14✔
365

366
        if (null === $composer) {
14✔
367
            return null;
14✔
368
        }
369

370
        $extra = $composer->getPackage()->getExtra();
1✔
371
        /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
372
        $configFile = $extra['version-bumper']['config-file'] ?? null;
1✔
373

374
        if (is_string($configFile) && '' !== trim($configFile)) {
1✔
375
            return $configFile;
1✔
376
        }
377

UNCOV
378
        return null;
×
379
    }
380

381
    private function updateComposerLockIfNeeded(
3✔
382
        Console\Input\InputInterface $input,
383
        Console\Output\OutputInterface $output,
384
        string $rootPath,
385
    ): int {
386
        $composer = $this->getComposerInstance();
3✔
387

388
        if (null === $composer || !$composer->getLocker()->isLocked() || $composer->getLocker()->isFresh()) {
3✔
389
            return self::SUCCESS;
3✔
390
        }
391

NEW
UNCOV
392
        $application = new Application();
×
NEW
UNCOV
393
        $application->setAutoExit(false);
×
NEW
UNCOV
394
        $commandOutput = new Console\Output\BufferedOutput(
×
NEW
395
            $output->getVerbosity(),
×
NEW
396
            $output->isDecorated(),
×
NEW
397
            $output->getFormatter(),
×
NEW
398
        );
×
399

NEW
400
        $parameters = [
×
NEW
401
            'command' => 'update',
×
NEW
402
            '--ignore-platform-reqs' => true,
×
NEW
403
            '--lock' => true,
×
NEW
404
            '--no-autoloader' => true,
×
NEW
405
            '--working-dir' => $rootPath,
×
NEW
406
        ];
×
407

NEW
408
        if ($input->hasParameterOption('--no-ansi')) {
×
NEW
409
            $parameters['--no-ansi'] = $input->getParameterOption('--no-ansi');
×
410
        }
411

NEW
412
        if ($input->hasParameterOption('--no-plugins')) {
×
NEW
413
            $parameters['--no-plugins'] = $input->getParameterOption('--no-plugins');
×
414
        }
415

NEW
416
        if ($input->hasParameterOption('--no-scripts')) {
×
NEW
417
            $parameters['--no-scripts'] = $input->getParameterOption('--no-scripts');
×
418
        }
419

420
        try {
NEW
421
            $result = $application->run(new Console\Input\ArrayInput($parameters), $commandOutput);
×
NEW
422
        } catch (\Exception $exception) {
×
NEW
423
            $this->io->write($commandOutput->fetch());
×
NEW
424
            $this->io->error($exception->getMessage());
×
425

NEW
426
            return self::FAILURE;
×
427
        }
428

NEW
429
        if (self::SUCCESS === $result) {
×
NEW
430
            return self::SUCCESS;
×
431
        }
432

NEW
433
        if ($output->isVerbose()) {
×
NEW
434
            $this->io->write($commandOutput->fetch());
×
435
        }
436

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

NEW
439
        return $result;
×
440
    }
441

442
    private function getComposerInstance(): ?Composer
14✔
443
    {
444
        // Composer >= 2.3
445
        if (method_exists($this, 'tryComposer')) {
14✔
446
            return $this->tryComposer();
14✔
447
        }
448

449
        // Composer < 2.3
NEW
450
        return $this->getComposer(false);
×
451
    }
452
}
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