From c3779027e6ab97e53161bf38b75dd5c825f39e61 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 11:08:01 +0200 Subject: [PATCH 1/2] feat(release): ability to increment minor and major version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways to raise a minor or a major version, for the two moments when it actually becomes known: * `alex pubspec version ` (alias `alex pub version`) — during the work on a task. 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 it too. * `alex release start --increment=` (`-i`) — at the release time. The version of this release is incremented before the release is started, keeping the build number, so the release branch, CHANGELOG.md, the tag and build artifacts use the new value. After the release a patch and a build number are incremented in develop, as always. Increment itself is extracted in VersionIncrement, and the replacement of the version in pubspec.yaml — in Spec.replaceVersion: it replaces only the value of the top level `version` key, keeps a trailing comment of the line and fails if there is no version definition, instead of the silent no-op of the previous `replaceFirst("version: $value")`. It's called through the FileSystem, so the demonstration mode doesn't write in the real pubspec.yaml anymore. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + README.md | 63 +++++++++++ lib/commands/pubspec/pubspec_command.dart | 2 + lib/commands/pubspec/version_command.dart | 81 ++++++++++++++ .../release/start_release_command.dart | 101 +++++++++++++----- lib/src/pub_spec.dart | 30 ++++++ lib/src/version_increment.dart | 64 +++++++++++ test/src/pub_spec_test.dart | 74 +++++++++++++ test/src/version_increment_test.dart | 76 +++++++++++++ 9 files changed, 469 insertions(+), 24 deletions(-) create mode 100644 lib/commands/pubspec/version_command.dart create mode 100644 lib/src/version_increment.dart create mode 100644 test/src/pub_spec_test.dart create mode 100644 test/src/version_increment_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c379c2f..1a0fb46 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`: 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. * **Fixed**: alex hangs if a command it runs asks for an input. For example, `alex code gen` stopped with no output when `fvm` asked to install a missing Flutter SDK. Such a question can't be answered anyway, because the command gets a pipe instead of the terminal, so its input is closed right after the start now, and the command fails with its own message instead of hanging. 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/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 6a94799..36849fe 100644 --- a/lib/commands/release/start_release_command.dart +++ b/lib/commands/release/start_release_command.dart @@ -12,6 +12,7 @@ import 'package:alex/src/l10n/comparers/arb_comparer.dart'; import 'package:alex/src/l10n/locale/locales.dart'; import 'package:alex/src/release/build_artifact_name.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'; @@ -34,6 +35,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; @@ -54,6 +56,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', @@ -182,15 +202,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; @@ -211,13 +248,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(); @@ -309,7 +357,7 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { } // increment version - incrementVersion(spec, version); + final nextVersion = await incrementVersion(version); _commit("Version increment"); @@ -323,6 +371,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:'); @@ -579,14 +628,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) { @@ -955,12 +1014,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/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/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/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())); + }); + }); +} From 8fe30386ee784b73336e413e6a5155b0bd5991b2 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 11:08:17 +0200 Subject: [PATCH 2/2] chore: format code with dart format No changes in the behavior, only the formatting of the files which were not formatted before. Co-Authored-By: Claude Opus 5 (1M context) --- lib/commands/code/generate_command.dart | 7 +- lib/commands/custom/add_custom_command.dart | 66 +++++--- .../custom/check_custom_commands.dart | 10 +- lib/commands/custom/list_custom_commands.dart | 3 +- lib/commands/custom/show_custom_command.dart | 3 +- lib/commands/custom/user_custom_command.dart | 5 +- lib/runner/alex_command_runner.dart | 9 +- lib/src/changelog/changelog.dart | 5 +- .../custom_command_config.dart | 27 ++-- .../custom_command_executor.dart | 142 ++++++++++++------ lib/src/l10n/exporters/arb_exporter.dart | 5 +- test/src/changelog/changelog_test.dart | 27 ++-- test/src/release/release_rollback_test.dart | 13 +- test/src/run/cmd_test.dart | 9 +- 14 files changed, 216 insertions(+), 115 deletions(-) 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/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/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/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'));