From ee0fdcf2d55572582b129bd72ae8684bfc555b4f Mon Sep 17 00:00:00 2001 From: SF97 Date: Wed, 27 May 2026 20:31:31 +0200 Subject: [PATCH 1/3] Add squash commit support --- lib/commands/feature/finish_command.dart | 61 ++++++++++------ lib/src/git/git.dart | 89 +++++++++++++++++------- 2 files changed, 102 insertions(+), 48 deletions(-) diff --git a/lib/commands/feature/finish_command.dart b/lib/commands/feature/finish_command.dart index c71501b..01125ef 100644 --- a/lib/commands/feature/finish_command.dart +++ b/lib/commands/feature/finish_command.dart @@ -13,13 +13,14 @@ 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( - 'finish', - 'Finish feature by issue id: ' - 'merge and remove branch, and update changelog.', - const ['f']) { + 'finish', + 'Finish feature by issue id: ' + 'merge and remove branch, and update changelog.', + const ['f']) { argParser ..addFlag( _argDemo.name, @@ -30,13 +31,19 @@ class FinishCommand extends FeatureCommandBase { help: 'Issue number, which used for branch name. ' 'Optional, you can provide it in interactive mode.', valueHelp: 'NUMBER', - ) - ..addArg( - _argChangelog, - help: 'Line to add in CHANGELOG.md. ' - 'Optional, you can provide it in interactive mode. ' - 'Example: alex finish feature -${_argChangelog.abbr}"Some new feature"', - valueHelp: 'CHANGELOG', + )..addArg( + _argChangelog, + help: 'Line to add in CHANGELOG.md. ' + '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(); @@ -159,7 +175,7 @@ class FinishCommand extends FeatureCommandBase { printVerbose('Branches: $branchesNames'); final branches = - branchesNames.map((n) => _Branch(git, n)).where((b) => b.isFeature); + branchesNames.map((n) => _Branch(git, n)).where((b) => b.isFeature); if (branches.isEmpty) return []; @@ -228,7 +244,9 @@ Which section to add: ?'''); final sectionInput = console.readLineSync(); - if (sectionInput == null || sectionInput.trim().isEmpty) { + if (sectionInput == null || sectionInput + .trim() + .isEmpty) { printInfo('Use default Added'); section = 1; } else { @@ -262,6 +280,7 @@ Which section to add: class _Branch { final GitCommands git; final String name; + // TODO: multiple remotes final String? remoteName; final String? localName; @@ -291,7 +310,8 @@ class _Branch { bool isIssueFeature(int issueId) => name.startsWith('${git.branchFeaturePrefix}$issueId.'); - _Branch merge(_Branch other) => _Branch._( + _Branch merge(_Branch other) => + _Branch._( git, name, localName ?? other.localName, @@ -304,10 +324,7 @@ class _Branch { String toString() { final sb = StringBuffer(name); if (remoteName != null) { - sb - ..write(' [') - ..write(remoteName) - ..write(']'); + sb..write(' [')..write(remoteName)..write(']'); } return sb.toString(); } diff --git a/lib/src/git/git.dart b/lib/src/git/git.dart index 5d8b2eb..99974cd 100644 --- a/lib/src/git/git.dart +++ b/lib/src/git/git.dart @@ -89,16 +89,13 @@ class GitCommands { bool printChanges = false, }) { ensure( - () => status("check status of current branch", porcelain: true), - (r) => r != "", - (r) { + () => status("check status of current branch", porcelain: true), + (r) => r != "", + (r) { final sb = StringBuffer( 'There are uncommitted changes. Commit or reset them to proceed.'); if (printChanges) { - sb - ..writeln() - ..writeln('Changes:') - ..writeln(r); + sb..writeln()..writeln('Changes:')..writeln(r); } return sb.toString().trim(); }, @@ -108,14 +105,14 @@ class GitCommands { void ensureRemoteUrl() { // TODO: not sure that's correct ensure( - () => remoteGetUrl("ensure that upstream remote is valid"), - (r) { + () => remoteGetUrl("ensure that upstream remote is valid"), + (r) { // print("r: " + r); return !(r.startsWith("http") && r.length > 8 || r.startsWith('git@') && r.endsWith('.git')); }, - (r) => - 'Current directory has no valid upstream setting. Check remote URL.', + (r) => + 'Current directory has no valid upstream setting. Check remote URL.', ); } @@ -141,10 +138,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}) { @@ -177,10 +184,10 @@ class GitCommands { return res .split('\n') .map((line) { - // First two characters = status - // From 4th character onwards = file path - return line.substring(3); - }) + // First two characters = status + // From 4th character onwards = file path + return line.substring(3); + }) .where((line) => line.isNotEmpty) .toList(); } @@ -196,14 +203,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"); } } @@ -220,7 +227,7 @@ class GitCommands { if (!failOnMergeConflict && e.exitCode == 1 && (e.message?.contains( - 'Automatic merge failed; fix conflicts and then commit the result.') ?? + 'Automatic merge failed; fix conflicts and then commit the result.') ?? false)) { print.info('alex will continue after merge would be resolved'); do { @@ -241,6 +248,38 @@ 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); @@ -311,11 +350,9 @@ class GitCommands { return res.split('\n').map((e) => e.trim()).where((e) => e.isNotEmpty); } - void ensure( - String Function() action, - bool Function(String) isFailed, - String Function(String) message, - ) { + void ensure(String Function() action, + bool Function(String) isFailed, + String Function(String) message,) { final res = action(); if (isFailed(res)) { fail(message(res)); From b1339d8377b76a4a95214891b893a327badfebc2 Mon Sep 17 00:00:00 2001 From: SF97 Date: Wed, 27 May 2026 20:36:32 +0200 Subject: [PATCH 2/3] Changelog and version increment --- CHANGELOG.md | 4 ++++ lib/src/version.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89a791..8ee1908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 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. + ## 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/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" From 121c75ceeb154349f48211a01b47c410e21135d5 Mon Sep 17 00:00:00 2001 From: SF97 Date: Thu, 28 May 2026 09:54:52 +0200 Subject: [PATCH 3/3] Fix from_xml plural validation for one/two quantities --- CHANGELOG.md | 1 + lib/commands/feature/finish_command.dart | 38 +++++++++---------- lib/src/git/git.dart | 48 +++++++++++++----------- lib/src/l10n/exporters/arb_exporter.dart | 27 ++++++++----- 4 files changed, 64 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee1908..763bde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 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 diff --git a/lib/commands/feature/finish_command.dart b/lib/commands/feature/finish_command.dart index 01125ef..ae91719 100644 --- a/lib/commands/feature/finish_command.dart +++ b/lib/commands/feature/finish_command.dart @@ -17,10 +17,10 @@ class FinishCommand extends FeatureCommandBase { FinishCommand() : super( - 'finish', - 'Finish feature by issue id: ' - 'merge and remove branch, and update changelog.', - const ['f']) { + 'finish', + 'Finish feature by issue id: ' + 'merge and remove branch, and update changelog.', + const ['f']) { argParser ..addFlag( _argDemo.name, @@ -31,14 +31,14 @@ class FinishCommand extends FeatureCommandBase { help: 'Issue number, which used for branch name. ' 'Optional, you can provide it in interactive mode.', valueHelp: 'NUMBER', - )..addArg( - _argChangelog, - help: 'Line to add in CHANGELOG.md. ' - 'Optional, you can provide it in interactive mode. ' - 'Example: alex finish feature -${_argChangelog - .abbr}"Some new feature"', - valueHelp: 'CHANGELOG', - ) + ) + ..addArg( + _argChangelog, + help: 'Line to add in CHANGELOG.md. ' + '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, @@ -175,7 +175,7 @@ class FinishCommand extends FeatureCommandBase { printVerbose('Branches: $branchesNames'); final branches = - branchesNames.map((n) => _Branch(git, n)).where((b) => b.isFeature); + branchesNames.map((n) => _Branch(git, n)).where((b) => b.isFeature); if (branches.isEmpty) return []; @@ -244,9 +244,7 @@ Which section to add: ?'''); final sectionInput = console.readLineSync(); - if (sectionInput == null || sectionInput - .trim() - .isEmpty) { + if (sectionInput == null || sectionInput.trim().isEmpty) { printInfo('Use default Added'); section = 1; } else { @@ -310,8 +308,7 @@ class _Branch { bool isIssueFeature(int issueId) => name.startsWith('${git.branchFeaturePrefix}$issueId.'); - _Branch merge(_Branch other) => - _Branch._( + _Branch merge(_Branch other) => _Branch._( git, name, localName ?? other.localName, @@ -324,7 +321,10 @@ class _Branch { String toString() { final sb = StringBuffer(name); if (remoteName != null) { - sb..write(' [')..write(remoteName)..write(']'); + sb + ..write(' [') + ..write(remoteName) + ..write(']'); } return sb.toString(); } diff --git a/lib/src/git/git.dart b/lib/src/git/git.dart index 99974cd..f03e353 100644 --- a/lib/src/git/git.dart +++ b/lib/src/git/git.dart @@ -89,13 +89,16 @@ class GitCommands { bool printChanges = false, }) { ensure( - () => status("check status of current branch", porcelain: true), - (r) => r != "", - (r) { + () => status("check status of current branch", porcelain: true), + (r) => r != "", + (r) { final sb = StringBuffer( 'There are uncommitted changes. Commit or reset them to proceed.'); if (printChanges) { - sb..writeln()..writeln('Changes:')..writeln(r); + sb + ..writeln() + ..writeln('Changes:') + ..writeln(r); } return sb.toString().trim(); }, @@ -105,14 +108,14 @@ class GitCommands { void ensureRemoteUrl() { // TODO: not sure that's correct ensure( - () => remoteGetUrl("ensure that upstream remote is valid"), - (r) { + () => remoteGetUrl("ensure that upstream remote is valid"), + (r) { // print("r: " + r); return !(r.startsWith("http") && r.length > 8 || r.startsWith('git@') && r.endsWith('.git')); }, - (r) => - 'Current directory has no valid upstream setting. Check remote URL.', + (r) => + 'Current directory has no valid upstream setting. Check remote URL.', ); } @@ -139,9 +142,9 @@ class GitCommands { void gitflowFeatureFinish(String branchName, {bool deleteBranch = true, - bool failOnMergeConflict = false, - bool squash = false, - String? squashMessage}) { + bool failOnMergeConflict = false, + bool squash = false, + String? squashMessage}) { checkout(branchDevelop); if (squash) { mergeSquash(branchName, squashMessage ?? "Merge branch '$branchName'", @@ -184,10 +187,10 @@ class GitCommands { return res .split('\n') .map((line) { - // First two characters = status - // From 4th character onwards = file path - return line.substring(3); - }) + // First two characters = status + // From 4th character onwards = file path + return line.substring(3); + }) .where((line) => line.isNotEmpty) .toList(); } @@ -227,7 +230,7 @@ class GitCommands { if (!failOnMergeConflict && e.exitCode == 1 && (e.message?.contains( - 'Automatic merge failed; fix conflicts and then commit the result.') ?? + 'Automatic merge failed; fix conflicts and then commit the result.') ?? false)) { print.info('alex will continue after merge would be resolved'); do { @@ -258,7 +261,7 @@ class GitCommands { if (!failOnMergeConflict && e.exitCode == 1 && (e.message?.contains( - 'Automatic merge failed; fix conflicts and then commit the result.') ?? + 'Automatic merge failed; fix conflicts and then commit the result.') ?? false)) { print.info('alex will continue after merge would be resolved'); do { @@ -275,8 +278,7 @@ class GitCommands { } bool hasUnmergedPaths() { - final res = - git('diff --name-only --diff-filter=U', 'check unmerged paths'); + final res = git('diff --name-only --diff-filter=U', 'check unmerged paths'); return res.isNotEmpty; } @@ -350,9 +352,11 @@ class GitCommands { return res.split('\n').map((e) => e.trim()).where((e) => e.isNotEmpty); } - void ensure(String Function() action, - bool Function(String) isFailed, - String Function(String) message,) { + void ensure( + String Function() action, + bool Function(String) isFailed, + String Function(String) message, + ) { final res = action(); if (isFailed(res)) { fail(message(res)); 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(', ')}.'); } }