diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d707a..445abb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +* New command `alex pubspec version ` (alias `alex pub version`) — increments the version in `pubspec.yaml`. By default only the version is changed and a build number is kept as is (usually it's already prepared for the next build); pass `--build` (`-b`) to increment the build number too (#12). +* [Release] `start`: new `--increment` (`-i`) option (`patch`, `minor` or `major`) to increment the version **of this release** before it's started, keeping the build number: `1.2.3+4` is released as `1.3.0+4` with `-i minor`. The release branch, `CHANGELOG.md`, the tag and build artifacts use the new version. Without the option nothing changes — the current version is released as is. After the release a patch and a build number are incremented in `develop` as always, and the new version is printed in the end (#12). * [Release] `start`: fixed a failure of a local iOS build (`--local`), when alex reported that the artifact was placed in an unexpected directory, though the `.ipa` was built correctly. `flutter build ipa` prints two `✓ Built` lines — for the Xcode archive and for the IPA itself — and alex checked the first one. Now the line with a path in the build output directory is used. * [Release] `start`: if the release failed, then all the changes made in the repository are rolled back automatically: uncommitted and untracked changes are discarded, release branch and tag are removed (only if they didn't exist before the release), and `develop` and `master` are restored to the state before the release. Every step is printed, and if the rollback itself fails, then instructions for a manual cleanup are shown. Nothing is rolled back as soon as the push is started — a push can update the remote even if it failed as a whole, and a published release should not be rewritten — in that case alex only reports that the release should be finished or reverted manually. * [Release] `start`: fix the length limit of the release notes in the form. The limit is enforced by alex now, instead of the `maxlength` attribute: a browser counts a newline as CRLF (2 characters), so a note with N newlines was cut N characters earlier than the store allows, and it was impossible to type anything while the counter still showed a value below the limit. The counter shows the number of characters as the store counts them, and it's highlighted if the value is longer than the limit. diff --git a/README.md b/README.md index c85f18d..b71edf3 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ _Note: You can change GIT branches, localization parameters, CI/CD and other set - `--check_locale=` (`-l`) - Locale to check before release if translations exist for all strings. If not specified, "en" locale will be checked. - `--skip_l10n` (`-s`) - Skip translations check during release. +- `--increment=` (`-i`) - Increment a part of the version **of this release** before it's started: `patch`, `minor` or `major`. Build number is kept as is. If not specified, the current version is released as is. - `--local` (`-b`) - Run local release build for Android and iOS platforms. - `--entry-point=` (`-e`) - Entry point of the app (e.g., lib/main_test.dart). Only for local release builds. - `--platforms=` (`-p`) - Target build platforms: ios, android. You can pass multiple platforms separated by commas. Defaults to "android,ios". Only for local release builds. @@ -135,6 +136,33 @@ otherwise the artifacts would be added in the release commit by `git add -A`. This is checked before the release is started, along with the write access to the directory, so the release will not be published if an artifact can't be copied. +**Version increment:** + +By default the version from `pubspec.yaml` is released as is, +and after the release is finished a patch and a build number are incremented +and committed in `develop` for the next release: `1.2.3+4` is released, +and `develop` continues with `1.2.4+5`. + +If it turns out at the release time that the release should be a minor +or a major one, pass `--increment` (`-i`). +The version is incremented **before** the release is started, +so the release branch, `CHANGELOG.md`, the tag and build artifacts +all use the new version. A build number is kept as is — +it's already prepared for this build: + +| `-i` | Released version | Version in `develop` after the release | +|-----------|------------------|----------------------------------------| +| _not set_ | `1.2.3+4` | `1.2.4+5` | +| `patch` | `1.2.4+4` | `1.2.5+5` | +| `minor` | `1.3.0+4` | `1.3.1+5` | +| `major` | `2.0.0+4` | `2.0.1+5` | + +A pre-release suffix is kept as is. + +If the version was already raised during the work on the task +(see [`alex pubspec version`](#version)), then nothing should be passed +at the release time — the current version is released. + **Pre-release scripts:** You can define pre-release scripts in your project's [configuration](#configuration): @@ -173,6 +201,11 @@ Skip translations check: $ alex release start --skip_l10n ``` +Release as a minor version (`1.2.3+4` is released as `1.3.0+4`): +```bash +$ alex release start --increment=minor +``` + ### Feature Work with feature branches and issues. @@ -393,6 +426,36 @@ or $ alex pub get ``` +#### Version + +Increment the version in `pubspec.yaml`. + +```bash +$ alex pubspec version +``` + +where `` is `patch`, `minor` or `major`. + +**Only the version is changed, a build number is kept as is**, because usually +it's already prepared for the next build. Pass `--build` (`-b`) to increment +the build number (after `+`) too. A pre-release suffix is always kept as is. + +| Command | `1.2.3+4` becomes | +|----------------------------------------|-------------------| +| `alex pubspec version patch` | `1.2.4+4` | +| `alex pubspec version minor` | `1.3.0+4` | +| `alex pubspec version major` | `2.0.0+4` | +| `alex pubspec version minor --build` | `1.3.0+5` | + +Use it when it becomes clear during the work on a task that the next release +should be a minor or a major one — raise the version right away, and then +release it as usual with `alex release start`. +If you realize it only at the release time, use the `--increment` option +of the [`release start`](#start-release) command instead. + +The command changes `pubspec.yaml` in the current directory +and doesn't commit anything. + ### Update Manage updates for `alex`. diff --git a/lib/commands/code/generate_command.dart b/lib/commands/code/generate_command.dart index 08c4487..ecdc695 100644 --- a/lib/commands/code/generate_command.dart +++ b/lib/commands/code/generate_command.dart @@ -80,8 +80,8 @@ class GenerateCommand extends CodeCommandBase { if (pubspec.isWorkspaceRoot()) { printVerbose('Workspace root found'); - final info = - workspaces.putIfAbsent(file.parent.path, () => _WorkspaceInfo(file)); + final info = workspaces.putIfAbsent( + file.parent.path, () => _WorkspaceInfo(file)); if (hasBuildRunner) info.rootHasBuildRunner = true; continue; } @@ -248,7 +248,8 @@ class _GenTarget { /// workspace generation, so the build step can skip its own pub get. final bool depsResolved; - _GenTarget(this.pubspec, {this.useWorkspace = false, this.depsResolved = false}); + _GenTarget(this.pubspec, + {this.useWorkspace = false, this.depsResolved = false}); } /// Aggregated info about a single pub workspace discovered during the scan. diff --git a/lib/commands/custom/add_custom_command.dart b/lib/commands/custom/add_custom_command.dart index 5a607b2..9f0675c 100644 --- a/lib/commands/custom/add_custom_command.dart +++ b/lib/commands/custom/add_custom_command.dart @@ -56,7 +56,8 @@ class AddCustomCommand extends AlexCommand { // Get actions final actions = []; while (true) { - final addAction = _prompt('Add action? (y/n)', defaultValue: actions.isEmpty ? 'y' : 'n'); + final addAction = _prompt('Add action? (y/n)', + defaultValue: actions.isEmpty ? 'y' : 'n'); if (addAction.toLowerCase() != 'y') { if (actions.isEmpty) { printError('At least one action is required'); @@ -111,7 +112,8 @@ class AddCustomCommand extends AlexCommand { if (name.isEmpty) return null; printInfo(' Type:'); - final typeStr = _promptChoice('Select type', ['option', 'flag'], defaultValue: 'option'); + final typeStr = _promptChoice('Select type', ['option', 'flag'], + defaultValue: 'option'); final type = typeStr.toLowerCase() == 'flag' ? CustomCommandArgumentType.flag : CustomCommandArgumentType.option; @@ -160,11 +162,13 @@ class AddCustomCommand extends AlexCommand { // Prompt with validation loop CustomCommandActionType? actionType; while (actionType == null) { - final input = _prompt(' Enter action type number (1-${allTypes.length})', defaultValue: '1'); + final input = _prompt(' Enter action type number (1-${allTypes.length})', + defaultValue: '1'); final number = int.tryParse(input); if (number == null || number < 1 || number > allTypes.length) { - printError('Invalid number. Please enter a number between 1 and ${allTypes.length}.'); + printError( + 'Invalid number. Please enter a number between 1 and ${allTypes.length}.'); continue; } @@ -235,7 +239,11 @@ class AddCustomCommand extends AlexCommand { final workingDir = _prompt(' Working directory (optional)'); final args = argsStr.isNotEmpty - ? argsStr.split(' ').map((e) => e.trim()).where((e) => e.isNotEmpty).toList() + ? argsStr + .split(' ') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList() : []; return ExecAction( @@ -271,7 +279,8 @@ class AddCustomCommand extends AlexCommand { CheckGitBranchAction _promptCheckGitBranchAction() { final branch = _prompt(' Branch name'); - final autoSwitchStr = _prompt(' Auto switch to branch if not on it? (y/n)', defaultValue: 'y'); + final autoSwitchStr = _prompt(' Auto switch to branch if not on it? (y/n)', + defaultValue: 'y'); final autoSwitch = autoSwitchStr.toLowerCase() == 'y'; return CheckGitBranchAction( @@ -292,9 +301,12 @@ class AddCustomCommand extends AlexCommand { DeleteFileAction _promptDeleteFileAction() { final path = _prompt(' File path to delete'); - final recursiveStr = _prompt(' Delete recursively (for directories)? (y/n)', defaultValue: 'n'); + final recursiveStr = _prompt( + ' Delete recursively (for directories)? (y/n)', + defaultValue: 'n'); final recursive = recursiveStr.toLowerCase() == 'y'; - final ignoreNotFoundStr = _prompt(' Ignore if not found? (y/n)', defaultValue: 'y'); + final ignoreNotFoundStr = + _prompt(' Ignore if not found? (y/n)', defaultValue: 'y'); final ignoreNotFound = ignoreNotFoundStr.toLowerCase() == 'y'; return DeleteFileAction( @@ -306,7 +318,9 @@ class AddCustomCommand extends AlexCommand { CheckFileExistsAction _promptCheckFileExistsAction() { final path = _prompt(' File/directory path to check'); - final shouldExistStr = _prompt(' Should exist? (y=must exist, n=must not exist)', defaultValue: 'y'); + final shouldExistStr = _prompt( + ' Should exist? (y=must exist, n=must not exist)', + defaultValue: 'y'); final shouldExist = shouldExistStr.toLowerCase() == 'y'; return CheckFileExistsAction( @@ -318,7 +332,8 @@ class AddCustomCommand extends AlexCommand { CopyFileAction _promptCopyFileAction() { final source = _prompt(' Source file path'); final destination = _prompt(' Destination file path'); - final overwriteStr = _prompt(' Overwrite if exists? (y/n)', defaultValue: 'n'); + final overwriteStr = + _prompt(' Overwrite if exists? (y/n)', defaultValue: 'n'); final overwrite = overwriteStr.toLowerCase() == 'y'; return CopyFileAction( @@ -351,7 +366,8 @@ class AddCustomCommand extends AlexCommand { CreateFileAction _promptCreateFileAction() { final path = _prompt(' File path to create'); final content = _prompt(' File content (optional, supports variables)'); - final overwriteStr = _prompt(' Overwrite if exists? (y/n)', defaultValue: 'n'); + final overwriteStr = + _prompt(' Overwrite if exists? (y/n)', defaultValue: 'n'); final overwrite = overwriteStr.toLowerCase() == 'y'; return CreateFileAction( @@ -363,7 +379,8 @@ class AddCustomCommand extends AlexCommand { CreateDirAction _promptCreateDirAction() { final path = _prompt(' Directory path to create'); - final recursiveStr = _prompt(' Create parent directories? (y/n)', defaultValue: 'y'); + final recursiveStr = + _prompt(' Create parent directories? (y/n)', defaultValue: 'y'); final recursive = recursiveStr.toLowerCase() == 'y'; return CreateDirAction( @@ -374,9 +391,11 @@ class AddCustomCommand extends AlexCommand { DeleteDirAction _promptDeleteDirAction() { final path = _prompt(' Directory path to delete'); - final recursiveStr = _prompt(' Delete recursively? (y/n)', defaultValue: 'y'); + final recursiveStr = + _prompt(' Delete recursively? (y/n)', defaultValue: 'y'); final recursive = recursiveStr.toLowerCase() == 'y'; - final ignoreNotFoundStr = _prompt(' Ignore if not found? (y/n)', defaultValue: 'y'); + final ignoreNotFoundStr = + _prompt(' Ignore if not found? (y/n)', defaultValue: 'y'); final ignoreNotFound = ignoreNotFoundStr.toLowerCase() == 'y'; return DeleteDirAction( @@ -414,7 +433,8 @@ class AddCustomCommand extends AlexCommand { AppendToFileAction _promptAppendToFileAction() { final path = _prompt(' File path'); final content = _prompt(' Content to append'); - final createIfMissingStr = _prompt(' Create file if missing? (y/n)', defaultValue: 'y'); + final createIfMissingStr = + _prompt(' Create file if missing? (y/n)', defaultValue: 'y'); final createIfMissing = createIfMissingStr.toLowerCase() == 'y'; return AppendToFileAction( @@ -427,7 +447,8 @@ class AddCustomCommand extends AlexCommand { PrependToFileAction _promptPrependToFileAction() { final path = _prompt(' File path'); final content = _prompt(' Content to prepend'); - final createIfMissingStr = _prompt(' Create file if missing? (y/n)', defaultValue: 'y'); + final createIfMissingStr = + _prompt(' Create file if missing? (y/n)', defaultValue: 'y'); final createIfMissing = createIfMissingStr.toLowerCase() == 'y'; return PrependToFileAction( @@ -441,7 +462,8 @@ class AddCustomCommand extends AlexCommand { final message = _prompt(' Message to print'); printInfo(' Level:'); - final level = _promptChoice('Select level', ['info', 'warning', 'error'], defaultValue: 'info'); + final level = _promptChoice('Select level', ['info', 'warning', 'error'], + defaultValue: 'info'); return PrintAction( message: message, @@ -462,7 +484,9 @@ class AddCustomCommand extends AlexCommand { CheckPlatformAction _promptCheckPlatformAction() { printInfo(' Expected platform:'); - final platform = _promptChoice('Select platform', ['macos', 'linux', 'windows'], defaultValue: 'macos'); + final platform = _promptChoice( + 'Select platform', ['macos', 'linux', 'windows'], + defaultValue: 'macos'); return CheckPlatformAction(platform: platform); } @@ -472,7 +496,8 @@ class AddCustomCommand extends AlexCommand { final destination = _prompt(' Destination archive path'); printInfo(' Archive format:'); - final format = _promptChoice('Select format', ['zip', 'tar.gz'], defaultValue: 'zip'); + final format = + _promptChoice('Select format', ['zip', 'tar.gz'], defaultValue: 'zip'); return CreateArchiveAction( source: source, @@ -540,7 +565,8 @@ class AddCustomCommand extends AlexCommand { } } - printError('Invalid choice. Please enter a number (1-${choices.length}) or one of: ${choices.join(", ")}'); + printError( + 'Invalid choice. Please enter a number (1-${choices.length}) or one of: ${choices.join(", ")}'); } } diff --git a/lib/commands/custom/check_custom_commands.dart b/lib/commands/custom/check_custom_commands.dart index d48dc79..d0c30ec 100644 --- a/lib/commands/custom/check_custom_commands.dart +++ b/lib/commands/custom/check_custom_commands.dart @@ -23,7 +23,8 @@ class CheckCustomCommandsCommand extends AlexCommand { printInfo('Custom commands config file not found.'); printInfo(''); printInfo('Expected location: alex_custom_commands.yaml'); - printInfo('Searched in current directory and parent directories (up to 10 levels)'); + printInfo( + 'Searched in current directory and parent directories (up to 10 levels)'); printInfo(''); printInfo('To create a config file, use:'); printInfo(' alex custom add'); @@ -54,7 +55,8 @@ class CheckCustomCommandsCommand extends AlexCommand { } if (yamlData is! YamlMap) { - printError('Invalid config file format: expected YAML map, got ${yamlData.runtimeType}'); + printError( + 'Invalid config file format: expected YAML map, got ${yamlData.runtimeType}'); return 1; } @@ -95,7 +97,9 @@ class CheckCustomCommandsCommand extends AlexCommand { printInfo(' Arguments: ${cmd.arguments.length}'); for (final arg in cmd.arguments) { final required = arg.required ? ' (required)' : ''; - final defaultVal = arg.defaultValue != null ? ' [default: ${arg.defaultValue}]' : ''; + final defaultVal = arg.defaultValue != null + ? ' [default: ${arg.defaultValue}]' + : ''; printInfo(' --${arg.name}$required$defaultVal'); } } diff --git a/lib/commands/custom/list_custom_commands.dart b/lib/commands/custom/list_custom_commands.dart index b7b09e8..9e1e6e5 100644 --- a/lib/commands/custom/list_custom_commands.dart +++ b/lib/commands/custom/list_custom_commands.dart @@ -39,7 +39,8 @@ class ListCustomCommandsCommand extends AlexCommand { printInfo(''); } - printInfo('Config file: ${config.configPath ?? config.getOrCreateConfigPath()}'); + printInfo( + 'Config file: ${config.configPath ?? config.getOrCreateConfigPath()}'); return 0; } diff --git a/lib/commands/custom/show_custom_command.dart b/lib/commands/custom/show_custom_command.dart index 34c2505..912dc08 100644 --- a/lib/commands/custom/show_custom_command.dart +++ b/lib/commands/custom/show_custom_command.dart @@ -5,8 +5,7 @@ import 'package:alex/src/custom_commands/custom_command_config.dart'; /// Show details of a custom command. class ShowCustomCommand extends AlexCommand { - ShowCustomCommand() - : super('show', 'Show details of a custom command.') { + ShowCustomCommand() : super('show', 'Show details of a custom command.') { argParser.addOption( 'name', abbr: 'n', diff --git a/lib/commands/custom/user_custom_command.dart b/lib/commands/custom/user_custom_command.dart index 6d70602..8d67283 100644 --- a/lib/commands/custom/user_custom_command.dart +++ b/lib/commands/custom/user_custom_command.dart @@ -62,9 +62,8 @@ class UserCustomCommand extends AlexCommand { try { // Create executor with verbose logger if needed - final executor = isVerbose - ? CustomCommandExecutor(logger: out) - : _executor; + final executor = + isVerbose ? CustomCommandExecutor(logger: out) : _executor; final exitCode = await executor.execute(_definition, arguments); return exitCode; diff --git a/lib/commands/pubspec/pubspec_command.dart b/lib/commands/pubspec/pubspec_command.dart index 266fea5..3641f78 100644 --- a/lib/commands/pubspec/pubspec_command.dart +++ b/lib/commands/pubspec/pubspec_command.dart @@ -1,4 +1,5 @@ import 'package:alex/commands/pubspec/update_command.dart'; +import 'package:alex/commands/pubspec/version_command.dart'; import 'package:alex/runner/alex_command.dart'; import 'get_command.dart'; @@ -8,6 +9,7 @@ class PubspecCommand extends AlexCommand { : super('pubspec', 'Work with pubspec.yaml.', const ['pub']) { addSubcommand(UpdateCommand()); addSubcommand(GetCommand()); + addSubcommand(VersionCommand()); } @override diff --git a/lib/commands/pubspec/version_command.dart b/lib/commands/pubspec/version_command.dart new file mode 100644 index 0000000..e2ec072 --- /dev/null +++ b/lib/commands/pubspec/version_command.dart @@ -0,0 +1,81 @@ +import 'package:alex/commands/pubspec/src/pubspec_command_base.dart'; +import 'package:alex/src/fs/fs.dart'; +import 'package:alex/src/pub_spec.dart'; +import 'package:alex/src/version_increment.dart'; + +/// Command to increment a version in the pubspec.yaml. +class VersionCommand extends PubspecCommandBase { + static const _argBuild = 'build'; + + VersionCommand() + : super( + 'version', + 'Increment a version in ${Spec.fileName}, ' + 'where is one of: ${VersionIncrement.namesList}. ' + 'A build number is kept as is, ' + 'if the --$_argBuild flag is not passed.', + ) { + argParser.addFlag( + _argBuild, + abbr: 'b', + help: 'Increment a build number (after +) too. ' + 'By default only a version is changed, ' + "because usually a build number is already prepared " + 'for the next build.', + ); + } + + @override + String get invocation => + super.invocation.replaceFirst('[arguments]', ' [arguments]'); + + @override + Future doRun() async { + final args = argResults!; + final rest = args.rest; + + if (rest.isEmpty) { + return error(1, + message: 'A part of the version to increment is required. ' + 'Allowed values: ${VersionIncrement.namesList}. ' + 'For example: alex pubspec version ${VersionIncrement.minor.name}.'); + } + + if (rest.length > 1) { + return error(1, + message: 'Only one part of the version can be incremented, ' + 'but got: ${rest.join(', ')}.'); + } + + // Throws a RunException with the allowed values if the part is unknown. + final increment = VersionIncrement.byName(rest.first); + final incrementBuild = args[_argBuild] as bool? ?? false; + + const fs = IOFileSystem(); + + if (!await Spec.exists(fs)) { + return error(1, + message: 'There is no ${Spec.fileName} in the current directory. ' + 'Run the command in a directory of a project.'); + } + + final spec = await Spec.pub(fs); + + final version = spec.versionOrNull; + if (version == null) { + return error(1, + message: 'There is no version in ${Spec.fileName}. ' + 'Add a line like "version: 1.0.0+1" in the file.'); + } + + final newVersion = increment.apply(version, incrementBuild: incrementBuild); + printVerbose( + 'Increment ${increment.name} version: $version -> $newVersion'); + + final content = await fs.readString(Spec.fileName); + await fs.writeString( + Spec.fileName, Spec.replaceVersion(content, newVersion)); + + return success(message: 'Version updated: $version -> $newVersion'); + } +} diff --git a/lib/commands/release/start_release_command.dart b/lib/commands/release/start_release_command.dart index e9d7f17..1ff1636 100644 --- a/lib/commands/release/start_release_command.dart +++ b/lib/commands/release/start_release_command.dart @@ -13,6 +13,7 @@ import 'package:alex/src/l10n/locale/locales.dart'; import 'package:alex/src/release/build_artifact_name.dart'; import 'package:alex/src/release/build_output.dart'; import 'package:alex/src/release/release_rollback.dart'; +import 'package:alex/src/version_increment.dart'; import 'package:dart_openai/openai.dart'; import 'package:list_ext/list_ext.dart'; import 'package:open_url/open_url.dart'; @@ -35,6 +36,7 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { static const _argBuildPlatforms = 'platforms'; static const _argTargetPath = 'target-path'; static const _argSkipL10n = 'skip_l10n'; + static const _argVersionIncrement = 'increment'; static const _argDemo = 'demo'; late FileSystem fs; @@ -55,6 +57,24 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { abbr: 's', help: 'Skip translations check during release.', ) + ..addOption( + _argVersionIncrement, + abbr: 'i', + help: 'Increment a part of the version of this release ' + 'before the release is started. ' + 'A build number is kept as is. ' + 'If not specified - the current version is released as is. ' + 'After the release a patch and a build number are incremented ' + 'in develop, as always.', + allowed: VersionIncrement.values.map((e) => e.name), + allowedHelp: { + VersionIncrement.patch.name: + 'Release 1.2.4+4 instead of 1.2.3+4 (rarely needed).', + VersionIncrement.minor.name: 'Release 1.3.0+4 instead of 1.2.3+4.', + VersionIncrement.major.name: 'Release 2.0.0+4 instead of 1.2.3+4.', + }, + valueHelp: 'PART', + ) ..addFlag( _argLocal, abbr: 'b', @@ -183,15 +203,32 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { final ciConfig = config.ci; final spec = await Spec.pub(fs); - final version = spec.version; - final vs = version.short; + final currentVersion = spec.version; - if (int.tryParse(version.build) == null) { + if (int.tryParse(currentVersion.build) == null) { return error(1, - message: 'Invalid version "$vs": ' + message: 'Invalid version "${currentVersion.short}": ' 'you should define build number (after +).'); } + // Parsed before the release is started, + // so an invalid value will not break the process in the middle. + final VersionIncrement? versionIncrement; + try { + final incrementArg = args[_argVersionIncrement] as String?; + versionIncrement = + incrementArg == null ? null : VersionIncrement.byName(incrementArg); + } on RunException catch (e) { + return errorBy(e); + } + + // The version of this release. It's incremented before the release, + // if it was requested, so the release itself + // (branch, changelog, tag, build artifacts) uses the new value. + // A build number is kept as is: it's already prepared for this build. + final version = versionIncrement?.apply(currentVersion) ?? currentVersion; + final vs = version.short; + // Prepare parameters of a local build before the release is started, // so an invalid value will not break the process in the middle. final _LocalBuildOptions? localBuildOptions; @@ -212,13 +249,24 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { buildPlatforms = const []; } - if (skipL10n) { - if (args.wasParsed(_argLocale)) { - return error(1, - message: - "You can't pass --$_argSkipL10n and --$_argLocale at the same time"); - } - } else { + if (skipL10n && args.wasParsed(_argLocale)) { + return error(1, + message: + "You can't pass --$_argSkipL10n and --$_argLocale at the same time"); + } + + // All the arguments are checked at this point, + // so the repository can be changed. + if (versionIncrement != null) { + printInfo('Increment ${versionIncrement.name} version of the release: ' + '$currentVersion -> $version'); + await _saveVersion(version); + // The message differs from the increment after the release, + // so both commits can be distinguished in the history. + _commit('Version increment to $vs'); + } + + if (!skipL10n) { final baseLocale = args.getLocale(_argLocale) ?? _defaultLocale.asXmlLocale(); @@ -310,7 +358,7 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { } // increment version - incrementVersion(spec, version); + final nextVersion = await incrementVersion(version); _commit("Version increment"); @@ -324,6 +372,7 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { git.push(branchMaster); printInfo('Release successfully completed'); + printInfo('Version in $branchDevelop is incremented to <$nextVersion>'); printInfo(''); printInfo( 'Release summary, copypaste it in the comment for release issue:'); @@ -580,14 +629,24 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { return File(await PathUtils.getAssetsPath(assetPath)).readAsString(); } - void incrementVersion(Spec spec, Version value) { + /// Increments the version [value] for the next release + /// and returns a new one. + /// + /// A patch and a build number are incremented, + /// no matter which part was incremented for the release itself. + Future incrementVersion(Version value) async { printVerbose('Increment version'); - final version = value.incrementPatchAndBuild(); - final content = spec.getContent(); - final updated = - content.replaceFirst("version: $value", "version: $version"); - spec.saveContent(updated); + final version = VersionIncrement.patch.apply(value, incrementBuild: true); + await _saveVersion(version); + + return version; + } + + /// Writes the [version] in the pubspec.yaml. + Future _saveVersion(Version version) async { + final content = await fs.readString(Spec.fileName); + await fs.writeString(Spec.fileName, Spec.replaceVersion(content, version)); } void _commit(String commitMessage) { @@ -949,12 +1008,6 @@ extension VersionExtension on Version { String get short { return "$major.$minor.$patch"; } - - Version incrementPatchAndBuild() { - final build = int.parse(this.build) + 1; - return Version(major, minor, patch + 1, - preRelease: preRelease, build: "$build"); - } } class _CIPath { diff --git a/lib/runner/alex_command_runner.dart b/lib/runner/alex_command_runner.dart index 979939b..98b402a 100644 --- a/lib/runner/alex_command_runner.dart +++ b/lib/runner/alex_command_runner.dart @@ -63,16 +63,19 @@ class AlexCommandRunner extends CommandRunner { _out.fine('Loading custom commands...'); CustomCommandsConfig.load(); final config = CustomCommandsConfig.instance; - _out.fine('Custom commands config loaded, found ${config.commands.length} command(s)'); + _out.fine( + 'Custom commands config loaded, found ${config.commands.length} command(s)'); for (final definition in config.commands) { try { _out.fine('Registering custom command: ${definition.name}'); final command = UserCustomCommand(definition); addCommand(command); - _out.info('Successfully registered custom command: ${definition.name}'); + _out.info( + 'Successfully registered custom command: ${definition.name}'); } catch (e, stackTrace) { - _out.severe('Failed to register custom command ${definition.name}: $e'); + _out.severe( + 'Failed to register custom command ${definition.name}: $e'); _out.fine('Stack trace: $stackTrace'); } } diff --git a/lib/src/changelog/changelog.dart b/lib/src/changelog/changelog.dart index 42feeb2..19bc708 100644 --- a/lib/src/changelog/changelog.dart +++ b/lib/src/changelog/changelog.dart @@ -99,8 +99,9 @@ class Changelog { /// `[issueUrl]/N`. Skips occurrences that are already part of a markdown /// link. Returns the number of replacements. Future linkIssueReferences(String issueUrl) async { - final base = - issueUrl.endsWith('/') ? issueUrl.substring(0, issueUrl.length - 1) : issueUrl; + final base = issueUrl.endsWith('/') + ? issueUrl.substring(0, issueUrl.length - 1) + : issueUrl; final str = await content; final pattern = RegExp(r'(?'; - var location = 'action ${i + 1} (type: $actionType) in command "$cmdName"'; + var location = + 'action ${i + 1} (type: $actionType) in command "$cmdName"'; try { final span = actionData.span; - location = 'line ${span.start.line + 1}:${span.start.column + 1} (action ${i + 1}, type: $actionType, command: "$cmdName")'; + location = + 'line ${span.start.line + 1}:${span.start.column + 1} (action ${i + 1}, type: $actionType, command: "$cmdName")'; } catch (_) { // Span not available } @@ -148,7 +151,8 @@ class CustomCommandsConfig { _logger.info('Found custom commands config at: $configPath'); try { _instance = _loadFromFile(configPath); - _logger.info('Successfully loaded custom commands config from: $configPath'); + _logger + .info('Successfully loaded custom commands config from: $configPath'); } catch (e, stackTrace) { _logger.severe('Failed to load custom commands config: $e'); _logger.fine('Stack trace: $stackTrace'); @@ -199,7 +203,8 @@ class CustomCommandsConfig { } if (yamlData is! YamlMap) { - throw Exception('Invalid config file format: expected YamlMap, got ${yamlData.runtimeType}'); + throw Exception( + 'Invalid config file format: expected YamlMap, got ${yamlData.runtimeType}'); } final commandsList = yamlData['custom_commands'] as YamlList?; @@ -231,7 +236,8 @@ class CustomCommandsConfig { var locationInfo = 'command ${i + 1} ($cmdName)'; try { final span = cmdData.span; - locationInfo = '$path:${span.start.line + 1}:${span.start.column + 1}'; + locationInfo = + '$path:${span.start.line + 1}:${span.start.column + 1}'; } catch (_) { // If span is not available, use simple format } @@ -309,7 +315,8 @@ class CustomCommandsConfig { buffer.writeln(' arguments:'); for (final arg in cmd.arguments) { buffer.writeln(' - name: ${arg.name}'); - buffer.writeln(' type: ${arg.type == CustomCommandArgumentType.option ? 'option' : 'flag'}'); + buffer.writeln( + ' type: ${arg.type == CustomCommandArgumentType.option ? 'option' : 'flag'}'); if (arg.help != null) { buffer.writeln(' help: ${arg.help}'); } @@ -343,7 +350,8 @@ class CustomCommandsConfig { } else if (value is Map) { buffer.writeln(' ${entry.key}:'); for (final subEntry in (value as Map).entries) { - buffer.writeln(' ${subEntry.key}: ${_escapeYamlString(subEntry.value.toString())}'); + buffer.writeln( + ' ${subEntry.key}: ${_escapeYamlString(subEntry.value.toString())}'); } } else if (value is String) { // Escape multiline strings @@ -353,7 +361,8 @@ class CustomCommandsConfig { buffer.writeln(' $line'); } } else { - buffer.writeln(' ${entry.key}: ${_escapeYamlString(value)}'); + buffer + .writeln(' ${entry.key}: ${_escapeYamlString(value)}'); } } else { buffer.writeln(' ${entry.key}: $value'); diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart index a6e7465..4e91832 100644 --- a/lib/src/custom_commands/custom_command_executor.dart +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -55,7 +55,8 @@ class CustomCommandExecutor { for (var i = 0; i < definition.actions.length; i++) { final action = definition.actions[i]; - _logger.fine('Executing action ${i + 1}/${definition.actions.length}: ${action.type.value}'); + _logger.fine( + 'Executing action ${i + 1}/${definition.actions.length}: ${action.type.value}'); final exitCode = await _executeAction(action, arguments); if (exitCode != 0) { @@ -65,7 +66,8 @@ class CustomCommandExecutor { } _logger.info('Custom command completed successfully'); - _logger.fine('Resource usage: $_filesCreatedCount files created, $_totalBytesWritten bytes written'); + _logger.fine( + 'Resource usage: $_filesCreatedCount files created, $_totalBytesWritten bytes written'); return 0; } @@ -81,15 +83,18 @@ class CustomCommandExecutor { case CustomCommandActionType.script: return _executeScriptAction(action as ScriptAction, arguments); case CustomCommandActionType.checkGitBranch: - return _executeCheckGitBranchAction(action as CheckGitBranchAction, arguments); + return _executeCheckGitBranchAction( + action as CheckGitBranchAction, arguments); case CustomCommandActionType.checkGitClean: - return _executeCheckGitCleanAction(action as CheckGitCleanAction, arguments); + return _executeCheckGitCleanAction( + action as CheckGitCleanAction, arguments); case CustomCommandActionType.changeDir: return _executeChangeDirAction(action as ChangeDirAction, arguments); case CustomCommandActionType.deleteFile: return _executeDeleteFileAction(action as DeleteFileAction, arguments); case CustomCommandActionType.checkFileExists: - return _executeCheckFileExistsAction(action as CheckFileExistsAction, arguments); + return _executeCheckFileExistsAction( + action as CheckFileExistsAction, arguments); case CustomCommandActionType.copyFile: return _executeCopyFileAction(action as CopyFileAction, arguments); case CustomCommandActionType.renameFile: @@ -105,21 +110,27 @@ class CustomCommandExecutor { case CustomCommandActionType.renameDir: return _executeRenameDirAction(action as RenameDirAction, arguments); case CustomCommandActionType.replaceInFile: - return _executeReplaceInFileAction(action as ReplaceInFileAction, arguments); + return _executeReplaceInFileAction( + action as ReplaceInFileAction, arguments); case CustomCommandActionType.appendToFile: - return _executeAppendToFileAction(action as AppendToFileAction, arguments); + return _executeAppendToFileAction( + action as AppendToFileAction, arguments); case CustomCommandActionType.prependToFile: - return _executePrependToFileAction(action as PrependToFileAction, arguments); + return _executePrependToFileAction( + action as PrependToFileAction, arguments); case CustomCommandActionType.print: return _executePrintAction(action as PrintAction, arguments); case CustomCommandActionType.wait: return _executeWaitAction(action as WaitAction, arguments); case CustomCommandActionType.checkPlatform: - return _executeCheckPlatformAction(action as CheckPlatformAction, arguments); + return _executeCheckPlatformAction( + action as CheckPlatformAction, arguments); case CustomCommandActionType.createArchive: - return _executeCreateArchiveAction(action as CreateArchiveAction, arguments); + return _executeCreateArchiveAction( + action as CreateArchiveAction, arguments); case CustomCommandActionType.extractArchive: - return _executeExtractArchiveAction(action as ExtractArchiveAction, arguments); + return _executeExtractArchiveAction( + action as ExtractArchiveAction, arguments); } } @@ -128,7 +139,10 @@ class CustomCommandExecutor { Map arguments, ) async { // Substitute variables only in arguments, not in executable - final args = action.args?.map((arg) => _substituteVariables(arg, arguments)).toList() ?? []; + final args = action.args + ?.map((arg) => _substituteVariables(arg, arguments)) + .toList() ?? + []; // SECURITY: Validate working directory if specified String? validatedWorkingDir; @@ -169,9 +183,8 @@ class CustomCommandExecutor { Map arguments, ) async { final command = _substituteVariables(action.command, arguments); - final args = action.args - .map((arg) => _substituteVariables(arg, arguments)) - .toList(); + final args = + action.args.map((arg) => _substituteVariables(arg, arguments)).toList(); _logger.info('Executing alex command: $command ${args.join(' ')}'); @@ -216,9 +229,8 @@ class CustomCommandExecutor { return 1; } - final args = action.args - .map((arg) => _substituteVariables(arg, arguments)) - .toList(); + final args = + action.args.map((arg) => _substituteVariables(arg, arguments)).toList(); _logger.info('Executing script: $scriptPath ${args.join(' ')}'); @@ -276,18 +288,19 @@ class CustomCommandExecutor { final normalizedBase = p.normalize(base); // Check if path is within allowed directory - if (!p.isWithin(normalizedBase, normalizedPath) && normalizedPath != normalizedBase) { + if (!p.isWithin(normalizedBase, normalizedPath) && + normalizedPath != normalizedBase) { throw ArgumentError( - 'Path traversal detected: "$path" resolves to "$normalizedPath" ' - 'which is outside allowed directory "$normalizedBase"' - ); + 'Path traversal detected: "$path" resolves to "$normalizedPath" ' + 'which is outside allowed directory "$normalizedBase"'); } return normalizedPath; } /// Safely substitute variables in path and validate it. - String _safelyResolvePath(String pathTemplate, Map arguments) { + String _safelyResolvePath( + String pathTemplate, Map arguments) { final pathInput = _substituteVariables(pathTemplate, arguments); return _validateAndResolvePath(pathInput); } @@ -338,10 +351,23 @@ class CustomCommandExecutor { } // Check for shell metacharacters that could cause issues - final dangerousChars = [';', '&', '|', '`', r'$', '(', ')', '<', '>', '\n', '\r']; + final dangerousChars = [ + ';', + '&', + '|', + '`', + r'$', + '(', + ')', + '<', + '>', + '\n', + '\r' + ]; for (final char in dangerousChars) { if (filename.contains(char)) { - _logger.severe('Invalid filename: contains dangerous character "$char": $filename'); + _logger.severe( + 'Invalid filename: contains dangerous character "$char": $filename'); return false; } } @@ -378,7 +404,8 @@ class CustomCommandExecutor { } if (!action.autoSwitch) { - final message = action.errorMessage ?? 'Not on branch: $branch (current: $currentBranch)'; + final message = action.errorMessage ?? + 'Not on branch: $branch (current: $currentBranch)'; _logger.severe(message); return 1; } @@ -436,7 +463,8 @@ class CustomCommandExecutor { final output = result.stdout.toString().trim(); if (output.isNotEmpty) { - final message = action.errorMessage ?? 'Git working directory is not clean'; + final message = + action.errorMessage ?? 'Git working directory is not clean'; _logger.severe(message); _logger.info('Uncommitted changes:\n$output'); return 1; @@ -550,7 +578,8 @@ class CustomCommandExecutor { _logger.severe(message); return 1; } else if (!action.shouldExist && exists) { - final message = action.errorMessage ?? 'File exists but should not: $path'; + final message = + action.errorMessage ?? 'File exists but should not: $path'; _logger.severe(message); return 1; } @@ -828,10 +857,12 @@ class CustomCommandExecutor { // Check file size to prevent memory issues final stat = await file.stat(); if (stat.size > _largeFileWarningSize) { - _logger.warning('File is very large (${stat.size} bytes), this may take a while'); + _logger.warning( + 'File is very large (${stat.size} bytes), this may take a while'); } if (stat.size > _maxFileSizeForProcessing) { - _logger.severe('File too large for in-memory processing: ${stat.size} bytes'); + _logger.severe( + 'File too large for in-memory processing: ${stat.size} bytes'); return 1; } @@ -841,13 +872,15 @@ class CustomCommandExecutor { if (action.regex) { // Validate regex pattern length to prevent injection if (find.length > _maxRegexPatternLength) { - _logger.severe('Regex pattern too complex (length > $_maxRegexPatternLength)'); + _logger.severe( + 'Regex pattern too complex (length > $_maxRegexPatternLength)'); return 1; } // Validate replacement string length if (replace.length > _maxReplacementLength) { - _logger.severe('Replacement string too long (length > $_maxReplacementLength)'); + _logger.severe( + 'Replacement string too long (length > $_maxReplacementLength)'); return 1; } @@ -980,10 +1013,12 @@ class CustomCommandExecutor { // Check file size before reading to prevent memory exhaustion final stat = await file.stat(); if (stat.size > _largeFileWarningSize) { - _logger.warning('File is very large (${stat.size} bytes), this may take a while'); + _logger.warning( + 'File is very large (${stat.size} bytes), this may take a while'); } if (stat.size > _maxFileSizeForProcessing) { - _logger.severe('File too large for in-memory processing: ${stat.size} bytes'); + _logger.severe( + 'File too large for in-memory processing: ${stat.size} bytes'); return 1; } @@ -1046,7 +1081,8 @@ class CustomCommandExecutor { final expectedPlatform = action.platform.toLowerCase(); final currentPlatform = _getCurrentPlatform(); - _logger.info('Checking platform: expected=$expectedPlatform, current=$currentPlatform'); + _logger.info( + 'Checking platform: expected=$expectedPlatform, current=$currentPlatform'); if (currentPlatform != expectedPlatform) { final errorMsg = action.errorMessage ?? @@ -1083,7 +1119,8 @@ class CustomCommandExecutor { // SECURITY: Validate filenames for archive operations final sourceBasename = p.basename(source); final destBasename = p.basename(destination); - if (!_validateArchiveFilename(sourceBasename) || !_validateArchiveFilename(destBasename)) { + if (!_validateArchiveFilename(sourceBasename) || + !_validateArchiveFilename(destBasename)) { return 1; } @@ -1128,7 +1165,8 @@ class CustomCommandExecutor { if (archiveFile.existsSync()) { try { archiveFile.deleteSync(); - _logger.info('Cleaned up partially created archive after error: $destination'); + _logger.info( + 'Cleaned up partially created archive after error: $destination'); } catch (cleanupError) { _logger.warning('Failed to cleanup partial archive: $cleanupError'); } @@ -1198,10 +1236,12 @@ class CustomCommandExecutor { destDir.deleteSync(); _logger.info('Cleaned up empty extraction directory: $destination'); } else { - _logger.warning('Extraction failed but directory contains files, not cleaning up: $destination'); + _logger.warning( + 'Extraction failed but directory contains files, not cleaning up: $destination'); } } catch (cleanupError) { - _logger.warning('Failed to cleanup extraction directory: $cleanupError'); + _logger + .warning('Failed to cleanup extraction directory: $cleanupError'); } } @@ -1214,13 +1254,16 @@ class CustomCommandExecutor { final isEmpty = destDir.listSync().isEmpty; if (isEmpty) { destDir.deleteSync(); - _logger.info('Cleaned up empty extraction directory after error: $destination'); + _logger.info( + 'Cleaned up empty extraction directory after error: $destination'); } else { - _logger.warning('Extraction failed but directory contains files, not cleaning up: $destination'); + _logger.warning( + 'Extraction failed but directory contains files, not cleaning up: $destination'); } } } catch (cleanupError) { - _logger.warning('Failed to cleanup extraction directory: $cleanupError'); + _logger + .warning('Failed to cleanup extraction directory: $cleanupError'); } } return _logError('Failed to extract archive', e, stackTrace); @@ -1233,7 +1276,8 @@ class CustomCommandExecutor { // that all files would be extracted within the destination directory. // // Returns true if archive is safe to extract, false otherwise. - Future _validateArchiveContents(String archivePath, String destDir) async { + Future _validateArchiveContents( + String archivePath, String destDir) async { final normalizedDest = p.normalize(p.absolute(destDir)); try { @@ -1246,7 +1290,8 @@ class CustomCommandExecutor { immediatePrintStd: false, immediatePrintErr: false, ); - } else if (archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tgz')) { + } else if (archivePath.endsWith('.tar.gz') || + archivePath.endsWith('.tgz')) { listResult = await _cmd.run( 'tar', arguments: ['-tzf', archivePath], @@ -1254,7 +1299,8 @@ class CustomCommandExecutor { immediatePrintErr: false, ); } else { - _logger.severe('Unsupported archive format for validation: $archivePath'); + _logger + .severe('Unsupported archive format for validation: $archivePath'); return false; } @@ -1273,7 +1319,8 @@ class CustomCommandExecutor { final targetPath = p.normalize(p.absolute(p.join(destDir, filename))); // Check if path escapes destination directory - if (!p.isWithin(normalizedDest, targetPath) && targetPath != normalizedDest) { + if (!p.isWithin(normalizedDest, targetPath) && + targetPath != normalizedDest) { _logger.severe('Archive contains path traversal: $filename'); _logger.severe('Would extract to: $targetPath'); _logger.severe('Outside allowed directory: $normalizedDest'); @@ -1281,7 +1328,8 @@ class CustomCommandExecutor { } } - _logger.fine('Archive validation passed: ${entries.length} entries checked'); + _logger + .fine('Archive validation passed: ${entries.length} entries checked'); return true; } catch (e, stackTrace) { _logger.severe('Archive validation error: $e'); diff --git a/lib/src/l10n/exporters/arb_exporter.dart b/lib/src/l10n/exporters/arb_exporter.dart index 44cf576..1238d90 100644 --- a/lib/src/l10n/exporters/arb_exporter.dart +++ b/lib/src/l10n/exporters/arb_exporter.dart @@ -65,9 +65,8 @@ class ArbExporter extends L10nExporter { // omit the count parameter (e.g. Arabic "كل يوم" / "كل يومين"), // so we don't require parameters there even if the base variant // uses them. Other quantities are still validated as usual. - final required = (attr == '=1' || attr == '=2') - ? const {} - : allowed; + final required = + (attr == '=1' || attr == '=2') ? const {} : allowed; return _addPlural(val, key, allowed, required, value, attr); }); val.write('}'); diff --git a/lib/src/pub_spec.dart b/lib/src/pub_spec.dart index b3d286f..5267fec 100644 --- a/lib/src/pub_spec.dart +++ b/lib/src/pub_spec.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:alex/src/exception/run_exception.dart'; import 'package:alex/src/fs/fs.dart'; import 'package:glob/glob.dart'; import 'package:glob/list_local_fs.dart'; @@ -15,6 +16,10 @@ class Spec { static final _pubspecSearch = Glob("**$fileName"); static final _logger = Logger('pubspec'); + /// A top level `version` key with its value. + static final _versionDefinition = + RegExp(r'^(version:[ \t]*)(\S+)', multiLine: true); + /// Returns specification of a project in current directory. static Future pub(FileSystem fs) async { final contents = await fs.readString(fileName); @@ -126,11 +131,36 @@ class Spec { Version get version => Version.parse( _yamlMap.version.valueOr(() => throw StateError('Version not found'))); + /// Returns version or `null` if there is no version in the pubspec. + Version? get versionOrNull { + final value = _yamlMap.version.valueOr(() => ''); + return value.isEmpty ? null : Version.parse(value); + } + /// Updates version. Spec setVersion(Version value) { return Spec(_yamlMap.copyWith(version: Optional.value("$value"))); } + /// Returns the [content] of a pubspec.yaml with the replaced version. + /// + /// Only the version value itself is replaced, + /// so the rest of the file is kept as is, including a trailing comment + /// of the version line. + /// + /// Throws a [RunException] if there is no version definition + /// in the [content]. + static String replaceVersion(String content, Version value) { + final match = _versionDefinition.firstMatch(content); + if (match == null) { + throw const RunException.err( + 'There is no version definition in $fileName. ' + 'Add a line like "version: 1.0.0+1" in the file.'); + } + + return content.replaceRange(match.start, match.end, '${match[1]}$value'); + } + String getContent() { final file = File(fileName); return file.readAsStringSync(); diff --git a/lib/src/version_increment.dart b/lib/src/version_increment.dart new file mode 100644 index 0000000..c02208d --- /dev/null +++ b/lib/src/version_increment.dart @@ -0,0 +1,64 @@ +import 'package:alex/src/exception/run_exception.dart'; +import 'package:version/version.dart'; + +/// A part of the version to increment. +enum VersionIncrement { + /// Increments a patch number: `1.2.3` -> `1.2.4`. + patch, + + /// Increments a minor number and resets a patch: `1.2.3` -> `1.3.0`. + minor, + + /// Increments a major number and resets minor and patch: + /// `1.2.3` -> `2.0.0`. + major; + + /// Names of all values, e.g. to print in a help or an error message. + static String get namesList => values.map((e) => e.name).join(', '); + + /// Returns a value by its [name]. + /// + /// Throws a [RunException] if there is no value with such a name. + static VersionIncrement byName(String name) { + final needle = name.trim().toLowerCase(); + for (final value in values) { + if (value.name == needle) return value; + } + + throw RunException.err( + 'Unknown version part <$name>. Allowed values: $namesList.'); + } + + /// Returns the [version] with the incremented part. + /// + /// A build number is incremented only if [incrementBuild] is `true`, + /// otherwise it's kept as is. + /// A pre-release suffix is always kept as is. + Version apply(Version version, {bool incrementBuild = false}) { + final String build; + if (incrementBuild) { + final buildNumber = int.tryParse(version.build); + if (buildNumber == null) { + throw RunException.err('Version "$version" has no valid build number, ' + "so the build number can't be incremented."); + } + build = '${buildNumber + 1}'; + } else { + build = version.build; + } + + final preRelease = version.preRelease; + + switch (this) { + case VersionIncrement.patch: + return Version(version.major, version.minor, version.patch + 1, + preRelease: preRelease, build: build); + case VersionIncrement.minor: + return Version(version.major, version.minor + 1, 0, + preRelease: preRelease, build: build); + case VersionIncrement.major: + return Version(version.major + 1, 0, 0, + preRelease: preRelease, build: build); + } + } +} diff --git a/test/src/changelog/changelog_test.dart b/test/src/changelog/changelog_test.dart index 5d53b93..6e69ca9 100644 --- a/test/src/changelog/changelog_test.dart +++ b/test/src/changelog/changelog_test.dart @@ -189,36 +189,45 @@ void main() { group('linkIssueReferences()', () { test('should convert plain (#N) to markdown links', () async { - final changelog = Changelog(_FileSystemMock(changelogWithPlainIssueReferences)); + final changelog = + Changelog(_FileSystemMock(changelogWithPlainIssueReferences)); - final count = await changelog.linkIssueReferences('https://example.com/issue'); + final count = + await changelog.linkIssueReferences('https://example.com/issue'); expect(count, 6); expect(await changelog.content, linkIssueReferencesResult); }); test('should handle trailing slash in issueUrl', () async { - final changelog = Changelog(_FileSystemMock(changelogWithPlainIssueReferences)); + final changelog = + Changelog(_FileSystemMock(changelogWithPlainIssueReferences)); - final count = await changelog.linkIssueReferences('https://example.com/issue/'); + final count = + await changelog.linkIssueReferences('https://example.com/issue/'); expect(count, 6); - expect(await changelog.content, linkIssueReferencesResultWithTrailingSlash); + expect( + await changelog.content, linkIssueReferencesResultWithTrailingSlash); }); test('should not modify already-linked issues', () async { - final changelog = Changelog(_FileSystemMock(changelogWithMixedIssueReferences)); + final changelog = + Changelog(_FileSystemMock(changelogWithMixedIssueReferences)); - final count = await changelog.linkIssueReferences('https://example.com/issue'); + final count = + await changelog.linkIssueReferences('https://example.com/issue'); expect(count, 3); expect(await changelog.content, linkIssueReferencesMixedResult); }); test('should return 0 when no plain issue references exist', () async { - final changelog = Changelog(_FileSystemMock(changelogWithNoIssueReferences)); + final changelog = + Changelog(_FileSystemMock(changelogWithNoIssueReferences)); - final count = await changelog.linkIssueReferences('https://example.com/issue'); + final count = + await changelog.linkIssueReferences('https://example.com/issue'); expect(count, 0); expect(await changelog.content, changelogWithNoIssueReferences); diff --git a/test/src/pub_spec_test.dart b/test/src/pub_spec_test.dart new file mode 100644 index 0000000..47ec7a2 --- /dev/null +++ b/test/src/pub_spec_test.dart @@ -0,0 +1,74 @@ +import 'package:alex/src/exception/run_exception.dart'; +import 'package:alex/src/pub_spec.dart'; +import 'package:test/test.dart'; +import 'package:version/version.dart'; + +void main() { + group('replaceVersion', () { + test('should replace a version and keep the rest of the file', () { + const content = 'name: demo\n' + 'description: A demo app.\n' + 'version: 1.2.3+4\n' + '\n' + 'environment:\n' + ' sdk: ">=3.0.0 <4.0.0"\n'; + + expect( + Spec.replaceVersion(content, Version.parse('1.3.0+4')), + 'name: demo\n' + 'description: A demo app.\n' + 'version: 1.3.0+4\n' + '\n' + 'environment:\n' + ' sdk: ">=3.0.0 <4.0.0"\n', + ); + }); + + test('should keep a comment of the version line', () { + const content = 'name: demo\n' + 'version: 1.2.3+4 # do not forget to increment\n'; + + expect( + Spec.replaceVersion(content, Version.parse('2.0.0+4')), + 'name: demo\n' + 'version: 2.0.0+4 # do not forget to increment\n', + ); + }); + + test('should not touch a version of a dependency', () { + const content = 'name: demo\n' + 'version: 1.2.3+4\n' + 'dependencies:\n' + ' some_package:\n' + ' version: 1.2.3+4\n'; + + expect( + Spec.replaceVersion(content, Version.parse('1.3.0+4')), + 'name: demo\n' + 'version: 1.3.0+4\n' + 'dependencies:\n' + ' some_package:\n' + ' version: 1.2.3+4\n', + ); + }); + + test('should fail if there is no version definition', () { + const content = 'name: demo\n' + 'description: A demo app.\n'; + + expect(() => Spec.replaceVersion(content, Version.parse('1.0.0+1')), + throwsA(isA())); + }); + }); + + group('versionOrNull', () { + test('should return a version if it is defined', () { + expect(Spec.byString('name: demo\nversion: 1.2.3+4\n').versionOrNull, + Version.parse('1.2.3+4')); + }); + + test('should return null if there is no version', () { + expect(Spec.byString('name: demo\n').versionOrNull, isNull); + }); + }); +} diff --git a/test/src/release/release_rollback_test.dart b/test/src/release/release_rollback_test.dart index de28fe1..0a428e4 100644 --- a/test/src/release/release_rollback_test.dart +++ b/test/src/release/release_rollback_test.dart @@ -73,7 +73,8 @@ void main() { test('discards uncommitted changes and untracked files', () { startRollback().run(); - expect(client.commands, containsAllInOrder(['reset --hard', 'clean -fd'])); + expect( + client.commands, containsAllInOrder(['reset --hard', 'clean -fd'])); }); test("doesn't touch branches if there are no new commits", () { @@ -101,8 +102,10 @@ void main() { rollback.run(); - expect(client.commands, - containsAllInOrder(['checkout $_master', 'reset --hard master_base'])); + expect( + client.commands, + containsAllInOrder( + ['checkout $_master', 'reset --hard master_base'])); }); test("doesn't restore a branch if its state was not saved", () { @@ -120,8 +123,8 @@ void main() { rollback.run(); expect(client.commands, isNot(contains('checkout $_master'))); - expect(client.commands.where((e) => e.startsWith('reset --hard ')), - isEmpty); + expect( + client.commands.where((e) => e.startsWith('reset --hard ')), isEmpty); }); test('deletes created release branch', () { diff --git a/test/src/run/cmd_test.dart b/test/src/run/cmd_test.dart index a3750b4..e7588cf 100644 --- a/test/src/run/cmd_test.dart +++ b/test/src/run/cmd_test.dart @@ -14,11 +14,10 @@ void main() { r'then echo "got: $line"; ' 'else echo "stdin closed" >&2; exit 3; fi'; - final result = await Cmd() - .run('sh', arguments: ['-c', script]) - .timeout(const Duration(seconds: 30), - onTimeout: () => - throw TimeoutException('Command is waiting for an input')); + final result = await Cmd().run('sh', arguments: ['-c', script]).timeout( + const Duration(seconds: 30), + onTimeout: () => + throw TimeoutException('Command is waiting for an input')); expect(result.exitCode, 3); expect(result.stderr, contains('stdin closed')); diff --git a/test/src/version_increment_test.dart b/test/src/version_increment_test.dart new file mode 100644 index 0000000..454d025 --- /dev/null +++ b/test/src/version_increment_test.dart @@ -0,0 +1,76 @@ +import 'package:alex/src/exception/run_exception.dart'; +import 'package:alex/src/version_increment.dart'; +import 'package:test/test.dart'; +import 'package:version/version.dart'; + +void main() { + group('byName', () { + test('should return a value by its name', () { + expect(VersionIncrement.byName('patch'), VersionIncrement.patch); + expect(VersionIncrement.byName('minor'), VersionIncrement.minor); + expect(VersionIncrement.byName('major'), VersionIncrement.major); + }); + + test('should ignore a case and spaces', () { + expect(VersionIncrement.byName(' Major '), VersionIncrement.major); + }); + + test('should fail for an unknown value', () { + expect( + () => VersionIncrement.byName('build'), throwsA(isA())); + }); + }); + + group('apply', () { + test('should keep a build number by default', () { + expect(VersionIncrement.patch.apply(Version.parse('1.2.3+4')).toString(), + '1.2.4+4'); + expect(VersionIncrement.minor.apply(Version.parse('1.2.3+4')).toString(), + '1.3.0+4'); + expect(VersionIncrement.major.apply(Version.parse('1.2.3+4')).toString(), + '2.0.0+4'); + }); + + test('should increment a build number if it is requested', () { + expect( + VersionIncrement.patch + .apply(Version.parse('1.2.3+4'), incrementBuild: true) + .toString(), + '1.2.4+5'); + expect( + VersionIncrement.minor + .apply(Version.parse('1.2.3+4'), incrementBuild: true) + .toString(), + '1.3.0+5'); + expect( + VersionIncrement.major + .apply(Version.parse('1.2.3+4'), incrementBuild: true) + .toString(), + '2.0.0+5'); + }); + + test('should keep a pre release', () { + expect( + VersionIncrement.minor + .apply(Version.parse('1.2.3-beta+4')) + .toString(), + '1.3.0-beta+4'); + }); + + test('should work for a version without a build number', () { + expect(VersionIncrement.minor.apply(Version.parse('1.2.3')).toString(), + '1.3.0'); + }); + + test('should fail to increment an invalid build number', () { + expect( + () => VersionIncrement.patch + .apply(Version.parse('1.2.3'), incrementBuild: true), + throwsA(isA())); + expect( + () => VersionIncrement.patch + .apply(Version.parse('1.2.3+beta'), incrementBuild: true), + throwsA(isA())); + }); + }); +}