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

eliashaeussler / version-bumper / 11860111625

15 Nov 2024 04:45PM UTC coverage: 66.911% (-21.1%) from 88.054%
11860111625

Pull #12

github

web-flow
Merge f526460ea into 70d8e62f5
Pull Request #12: [FEATURE] Introduce version range detector component

9 of 173 new or added lines in 14 files covered. (5.2%)

1 existing line in 1 file now uncovered.

457 of 683 relevant lines covered (66.91%)

2.6 hits per line

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

89.82
/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 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 Symfony\Component\Console;
35
use Symfony\Component\Filesystem;
36

37
use function count;
38
use function dirname;
39
use function getcwd;
40
use function implode;
41
use function is_string;
42
use function method_exists;
43
use function reset;
44
use function sprintf;
45
use function trim;
46

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

61
    public function __construct(?Composer $composer = null)
9✔
62
    {
63
        if (null !== $composer) {
9✔
64
            $this->setComposer($composer);
1✔
65
        }
66

67
        parent::__construct('bump-version');
9✔
68

69
        $this->bumper = new Version\VersionBumper();
9✔
70
        $this->configReader = new Config\ConfigReader();
9✔
71
        $this->versionRangeDetector = new Version\VersionRangeDetector();
9✔
72
        $this->releaser = new Version\VersionReleaser();
9✔
73
    }
74

75
    protected function configure(): void
9✔
76
    {
77
        $this->setAliases(['bv']);
9✔
78
        $this->setDescription('Bump package version in specific files during release preparations');
9✔
79

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

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

116
    protected function initialize(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): void
9✔
117
    {
118
        $this->io = new Console\Style\SymfonyStyle($input, $output);
9✔
119
    }
120

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

130
        if (null === $configFile) {
9✔
131
            $this->io->error('Please provide a config file path using the --config option.');
1✔
132

133
            return self::INVALID;
1✔
134
        }
135

136
        if (Filesystem\Path::isRelative($configFile)) {
8✔
137
            $configFile = Filesystem\Path::makeAbsolute($configFile, $rootPath);
1✔
138
        } else {
139
            $rootPath = dirname($configFile);
7✔
140
        }
141

142
        try {
143
            $result = $this->bumpVersions($configFile, $rangeOrVersion, $rootPath, $dryRun);
8✔
144

145
            if (null === $result) {
8✔
146
                return self::FAILURE;
4✔
147
            }
148

149
            [$config, $results] = $result;
4✔
150

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

160
        if ($strict) {
3✔
161
            foreach ($results as $versionBumpResult) {
1✔
162
                if ($versionBumpResult->hasUnmatchedReports()) {
1✔
163
                    return self::FAILURE;
1✔
164
                }
165
            }
166
        }
167

168
        return self::SUCCESS;
2✔
169
    }
170

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

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

NEW
195
                return null;
×
196
            }
197

198
            // Exit early if version range detection fails
199
            if (null === $versionRange) {
7✔
NEW
200
                $this->io->error('Unable to auto-detect version range. Please provide a version range or explicit version instead.');
×
201

NEW
202
                return null;
×
203
            }
204

205
            $results = $this->bumper->bump($config->filesToModify(), $finalRootPath, $versionRange, $dryRun);
7✔
206

207
            $this->decorateVersionBumpResults($results, $rootPath);
4✔
208

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

216
        return null;
4✔
217
    }
218

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

226
        try {
227
            $releaseResult = $this->releaser->release($results, $rootPath, $options, $dryRun);
2✔
228

229
            $this->decorateVersionReleaseResult($releaseResult);
1✔
230

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

238
        return false;
1✔
239
    }
240

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

248
        foreach ($results as $result) {
4✔
249
            if (!$result->hasOperations()) {
4✔
250
                continue;
4✔
251
            }
252

253
            $path = $result->file()->path();
4✔
254

255
            if (Filesystem\Path::isAbsolute($path)) {
4✔
256
                $path = Filesystem\Path::makeRelative($path, $rootPath);
×
257
            }
258

259
            if (!$titleDisplayed) {
4✔
260
                $this->io->title('Bumped versions');
4✔
261
                $titleDisplayed = true;
4✔
262
            }
263

264
            $this->io->section($path);
4✔
265

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

279
                if ($numberOfOperations > 1) {
4✔
280
                    $message .= sprintf(' (%dx)', $numberOfOperations);
4✔
281
                }
282

283
                $this->io->writeln($message);
4✔
284
            }
285
        }
286
    }
287

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

298
        if (null === $result->commitId()) {
1✔
299
            unset($releaseInformation[2]);
1✔
300
        }
301

302
        $this->io->listing($releaseInformation);
1✔
303
    }
304

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

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

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

318
        $this->io->listing($errorMessages);
1✔
319
    }
320

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

331
        if (null === $composer) {
9✔
332
            return null;
9✔
333
        }
334

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

339
        if (is_string($configFile) && '' !== trim($configFile)) {
1✔
340
            return $configFile;
1✔
341
        }
342

343
        return null;
×
344
    }
345
}
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