diff --git a/CHANGELOG.md b/CHANGELOG.md index f89a791..763bde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.11.1 + +* [Feature] `finish`: new `--squash` (`-s`) flag to squash all feature commits into a single commit when merging into develop. Useful for tasks like golden tests updates. +* **Fixed** [L10n] `from_xml`: false-positive "No parameters found" error for `one`/`two` plural variants when the target locale legitimately omits the count parameter (e.g. Arabic "كل يوم" / "كل يومين"). Other plural quantities (`zero`, `few`, `many`, `other`) are still validated as before. + ## 1.11.0 * [Feature] `finish`: append issue references as a markdown link (`[#N](url/N)`) when `issue_url` is set in alex config; falls back to the plain `(#N)` suffix otherwise. diff --git a/lib/commands/feature/finish_command.dart b/lib/commands/feature/finish_command.dart index c71501b..ae91719 100644 --- a/lib/commands/feature/finish_command.dart +++ b/lib/commands/feature/finish_command.dart @@ -13,6 +13,7 @@ class FinishCommand extends FeatureCommandBase { static const _argDemo = CmdArg('demo'); static const _argIssue = CmdArg('issue', abbr: 'i'); static const _argChangelog = CmdArg('changelog', abbr: 'c'); + static const _argSquash = CmdArg('squash', abbr: 's'); FinishCommand() : super( @@ -37,6 +38,12 @@ class FinishCommand extends FeatureCommandBase { 'Optional, you can provide it in interactive mode. ' 'Example: alex finish feature -${_argChangelog.abbr}"Some new feature"', valueHelp: 'CHANGELOG', + ) + ..addFlag( + _argSquash.name, + abbr: _argSquash.abbr, + help: 'Squash all feature commits into a single commit when merging ' + 'into develop. Useful for tasks like golden tests updates.', ); } @@ -47,6 +54,7 @@ class FinishCommand extends FeatureCommandBase { final isDemo = args.getBool(_argDemo); var issueId = args.getInt(_argIssue); final changelog = args.getString(_argChangelog); + final squash = args.getBool(_argSquash); final console = this.console; final gitConfig = config.git; @@ -105,8 +113,15 @@ class FinishCommand extends FeatureCommandBase { // TODO: Merge develop in remote feature branch if conflict - printVerbose('Merge feature branch in develop'); - git.gitflowFeatureFinish(branchName, deleteBranch: false); + printVerbose(squash + ? 'Squash merge feature branch in develop' + : 'Merge feature branch in develop'); + git.gitflowFeatureFinish( + branchName, + deleteBranch: false, + squash: squash, + squashMessage: 'Feature #$issueId: ${branch.name}.\n\nBy alex.', + ); printVerbose('Add entry in changelog'); final changed = await _updateChangelog( @@ -131,7 +146,8 @@ class FinishCommand extends FeatureCommandBase { if (localCommit == commonCommit) { printVerbose('Remove local feature branch'); - git.branchDelete(localName); + // Squash merge does not mark the branch as merged in git. + git.branchDelete(localName, force: squash); } else { printVerbose('Local branch different from remote. ' 'Do not delete $localName'); @@ -139,7 +155,7 @@ class FinishCommand extends FeatureCommandBase { } printVerbose('Remove feature branch'); - git.branchDelete(branchName); + git.branchDelete(branchName, force: squash); printVerbose('Merge develop in ${git.branchTest}'); git.mergeDevelopInTest(); @@ -262,6 +278,7 @@ Which section to add: class _Branch { final GitCommands git; final String name; + // TODO: multiple remotes final String? remoteName; final String? localName; diff --git a/lib/src/git/git.dart b/lib/src/git/git.dart index 5d8b2eb..f03e353 100644 --- a/lib/src/git/git.dart +++ b/lib/src/git/git.dart @@ -141,10 +141,20 @@ class GitCommands { } void gitflowFeatureFinish(String branchName, - {bool deleteBranch = true, bool failOnMergeConflict = false}) { + {bool deleteBranch = true, + bool failOnMergeConflict = false, + bool squash = false, + String? squashMessage}) { checkout(branchDevelop); - merge(branchName, failOnMergeConflict: failOnMergeConflict); - if (deleteBranch) branchDelete(branchName); + if (squash) { + mergeSquash(branchName, squashMessage ?? "Merge branch '$branchName'", + failOnMergeConflict: failOnMergeConflict); + } else { + merge(branchName, failOnMergeConflict: failOnMergeConflict); + } + // Squash merge does not mark the branch as merged, so a force delete + // is required to remove it. + if (deleteBranch) branchDelete(branchName, force: squash); } void mergeDevelopInTest({String? remote, bool failOnMergeConflict = false}) { @@ -196,14 +206,14 @@ class GitCommands { return git("fetch $remote", "fetch $remote"); } - void branchDelete(String branch) { + void branchDelete(String branch, {bool force = false}) { if (branch.startsWith(_branchRemotePrefix)) { final parts = branch.split(_sep); final remote = parts[1]; final remoteBranchName = parts.sublist(2).join(_sep); branchDeleteFromRemote(remoteBranchName, remote); } else { - git("branch -d $branch", "delete $branch"); + git("branch ${force ? '-D' : '-d'} $branch", "delete $branch"); } } @@ -241,6 +251,37 @@ class GitCommands { } } + /// Merges [branch] into the current branch squashing all its commits, + /// then records the result as a single commit with the given [message]. + void mergeSquash(String branch, String message, + {bool failOnMergeConflict = false}) { + try { + _git(["merge", "--squash", branch], "squash merge $branch"); + } on RunException catch (e) { + if (!failOnMergeConflict && + e.exitCode == 1 && + (e.message?.contains( + 'Automatic merge failed; fix conflicts and then commit the result.') ?? + false)) { + print.info('alex will continue after merge would be resolved'); + do { + sleep(const Duration(seconds: 1)); + // Squash merge does not set MERGE_HEAD, so we wait until there are + // no unmerged paths left instead of relying on isInMerge(). + } while (hasUnmergedPaths()); + } else { + rethrow; + } + } + + commit(message); + } + + bool hasUnmergedPaths() { + final res = git('diff --name-only --diff-filter=U', 'check unmerged paths'); + return res.isNotEmpty; + } + bool isInMerge() { try { _git(['merge', 'HEAD'], 'Check if in merge', printIfError: false); diff --git a/lib/src/l10n/exporters/arb_exporter.dart b/lib/src/l10n/exporters/arb_exporter.dart index 5e493a4..44cf576 100644 --- a/lib/src/l10n/exporters/arb_exporter.dart +++ b/lib/src/l10n/exporters/arb_exporter.dart @@ -34,7 +34,7 @@ class ArbExporter extends L10nExporter { (baseMeta['placeholders'] as Map).keys.toSet(); if (value is L10nTextEntry) { - map[key] = _processText(key, parameters, value.text); + map[key] = _processText(key, parameters, parameters, value.text); } else if (value is L10nPluralEntry) { // Получаем заголовочную часть const pluralPrefix = ',plural,'; @@ -61,7 +61,14 @@ class ArbExporter extends L10nExporter { : parameters .where((param) => baseVal.contains('{$param}')) .toSet(); - return _addPlural(val, key, allowed, value, attr); + // For "one"/"two" quantities the target locale may legitimately + // 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; + return _addPlural(val, key, allowed, required, value, attr); }); val.write('}'); @@ -85,18 +92,19 @@ class ArbExporter extends L10nExporter { } void _addPlural(StringBuffer res, String key, Set allowedParams, - String? val, String attr) { + Set requiredParams, String? val, String attr) { if (val != null) { res ..write(attr) ..write('{') - ..write(_processText(key, allowedParams, val)) + ..write(_processText(key, allowedParams, requiredParams, val)) ..write('}'); } } - String _processText(String key, Set allowed, String text) { - return clearWinLines(_validateParameters(key, allowed, text) + String _processText( + String key, Set allowed, Set required, String text) { + return clearWinLines(_validateParameters(key, allowed, required, text) .replaceAll(r'\n', '\n') .replaceAll(r'\r', '\r') // escape single quotes @@ -104,7 +112,8 @@ class ArbExporter extends L10nExporter { .replaceAll("'", "''")); } - String _validateParameters(String key, Set allowed, String text) { + String _validateParameters( + String key, Set allowed, Set required, String text) { final params = _paramRegExp.allMatches(text).map((e) => e.group(1)); if (params.isNotEmpty) { @@ -115,9 +124,9 @@ class ArbExporter extends L10nExporter { } } } else { - if (allowed.isNotEmpty) { + if (required.isNotEmpty) { throw Exception('[$locale] No parameters found for key "$key". ' - 'Expected: ${allowed.join(', ')}.'); + 'Expected: ${required.join(', ')}.'); } } diff --git a/lib/src/version.dart b/lib/src/version.dart index 4f8f0c1..307e298 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '1.11.0'; +const packageVersion = '1.11.1'; diff --git a/pubspec.yaml b/pubspec.yaml index 7e956cb..26f3ef9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ homepage: https://github.com/innim/ repository: https://github.com/Innim/alex issue_tracker: https://github.com/Innim/alex/issues -version: 1.11.0 +version: 1.11.1 environment: sdk: ">=3.0.0 <4.0.0"