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

eliashaeussler / version-bumper / 12732622834

12 Jan 2025 10:18AM UTC coverage: 89.29% (+0.5%) from 88.745%
12732622834

Pull #17

github

web-flow
Merge 43d7c218e into 0b06fcb19
Pull Request #17: [!!!][FEATURE] Introduce config presets

100 of 110 new or added lines in 6 files covered. (90.91%)

4 existing lines in 1 file now uncovered.

717 of 803 relevant lines covered (89.29%)

3.89 hits per line

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

97.01
/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-2025 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 count;
39
use function dirname;
40
use function getcwd;
41
use function implode;
42
use function is_string;
43
use function method_exists;
44
use function reset;
45
use function sprintf;
46
use function trim;
47

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

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

70
        parent::__construct('bump-version');
13✔
71

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

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

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

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

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

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

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

136
            return self::INVALID;
1✔
137
        }
138

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

145
        try {
146
            $result = $this->bumpVersions($configFile, $rangeOrVersion, $rootPath, $dryRun);
12✔
147

148
            if (null === $result) {
12✔
149
                return self::FAILURE;
7✔
150
            }
151

152
            [$config, $results] = $result;
5✔
153

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

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

171
        return self::SUCCESS;
3✔
172
    }
173

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

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

198
                return null;
1✔
199
            }
200

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

205
                return null;
1✔
206
            }
207

208
            $results = $this->bumper->bump($config->filesToModify(), $finalRootPath, $versionRange, $dryRun);
8✔
209

210
            $this->decorateVersionBumpResults($results, $rootPath);
5✔
211

212
            return [$config, $results];
5✔
213
        } catch (Valinor\Mapper\MappingError $error) {
5✔
214
            $this->decorateMappingError($error, $configFile);
1✔
215
        } catch (Exception\Exception $exception) {
4✔
216
            $this->io->error($exception->getMessage());
4✔
217
        }
218

219
        return null;
5✔
220
    }
221

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

229
        try {
230
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $dryRun);
2✔
231

232
            $this->decorateVersionReleaseResult($releaseResult);
1✔
233

234
            return true;
1✔
235
        } catch (Exception\Exception $exception) {
1✔
236
            $this->io->error($exception->getMessage());
1✔
UNCOV
237
        } catch (\Exception $exception) {
×
UNCOV
238
            $this->io->error('Git error during release: '.$exception->getMessage());
×
239
        }
240

241
        return false;
1✔
242
    }
243

244
    /**
245
     * @param list<Result\VersionBumpResult> $results
246
     */
247
    private function decorateVersionBumpResults(array $results, string $rootPath): void
5✔
248
    {
249
        $titleDisplayed = false;
5✔
250

251
        foreach ($results as $result) {
5✔
252
            if (!$result->hasOperations()) {
5✔
253
                continue;
4✔
254
            }
255

256
            $path = $result->file()->path();
5✔
257

258
            if (Filesystem\Path::isAbsolute($path)) {
5✔
259
                $path = Filesystem\Path::makeRelative($path, $rootPath);
×
260
            }
261

262
            if (!$titleDisplayed) {
5✔
263
                $this->io->title('Bumped versions');
5✔
264
                $titleDisplayed = true;
5✔
265
            }
266

267
            $this->io->section($path);
5✔
268

269
            foreach ($result->groupedOperations() as $operations) {
5✔
270
                $operation = reset($operations);
5✔
271
                $numberOfOperations = count($operations);
5✔
272
                $message = match ($operation->state()) {
5✔
273
                    Enum\OperationState::Modified => sprintf(
5✔
274
                        '✅ Bumped version from "%s" to "%s"',
5✔
275
                        $operation->source()?->full(),
5✔
276
                        $operation->target()?->full(),
5✔
277
                    ),
5✔
278
                    Enum\OperationState::Skipped => '⏩ Skipped file due to unmodified contents',
4✔
279
                    Enum\OperationState::Unmatched => '❓ Unmatched file pattern: '.$operation->pattern()->original(),
4✔
280
                };
5✔
281

282
                if ($numberOfOperations > 1) {
5✔
283
                    $message .= sprintf(' (%dx)', $numberOfOperations);
5✔
284
                }
285

286
                $this->io->writeln($message);
5✔
287
            }
288
        }
289
    }
290

291
    private function decorateVersionReleaseResult(Result\VersionReleaseResult $result): void
1✔
292
    {
293
        $numberOfCommittedFiles = count($result->committedFiles());
1✔
294
        $releaseInformation = [
1✔
295
            sprintf('Added %d file%s.', $numberOfCommittedFiles, 1 !== $numberOfCommittedFiles ? 's' : ''),
1✔
296
            sprintf('Committed: <info>%s</info>', $result->commitMessage()),
1✔
297
            sprintf('Commit hash: %s', $result->commitId()),
1✔
298
            sprintf('Tagged: <info>%s</info>', $result->tagName()),
1✔
299
        ];
1✔
300

301
        if (null === $result->commitId()) {
1✔
302
            unset($releaseInformation[2]);
1✔
303
        }
304

305
        $this->io->listing($releaseInformation);
1✔
306
    }
307

308
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
1✔
309
    {
310
        $errorMessages = [];
1✔
311
        $errors = Valinor\Mapper\Tree\Message\Messages::flattenFromNode($error->node())->errors();
1✔
312

313
        $this->io->error(
1✔
314
            sprintf('The config file "%s" is invalid.', $configFile),
1✔
315
        );
1✔
316

317
        foreach ($errors as $propertyError) {
1✔
318
            $errorMessages[] = sprintf('%s: %s', $propertyError->node()->path(), $propertyError->toString());
1✔
319
        }
320

321
        $this->io->listing($errorMessages);
1✔
322
    }
323

324
    private function readConfigFileFromRootPackage(): ?string
13✔
325
    {
326
        if (method_exists($this, 'tryComposer')) {
13✔
327
            // Composer >= 2.3
328
            $composer = $this->tryComposer();
13✔
329
        } else {
330
            // Composer < 2.3
UNCOV
331
            $composer = $this->getComposer(false);
×
332
        }
333

334
        if (null === $composer) {
13✔
335
            return null;
13✔
336
        }
337

338
        $extra = $composer->getPackage()->getExtra();
1✔
339
        /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
340
        $configFile = $extra['version-bumper']['config-file'] ?? null;
1✔
341

342
        if (is_string($configFile) && '' !== trim($configFile)) {
1✔
343
            return $configFile;
1✔
344
        }
345

UNCOV
346
        return null;
×
347
    }
348
}
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

© 2025 Coveralls, Inc