diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 35386bd5..5c54844f 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -110,10 +110,24 @@ ignore: - 'packages/**/example' ``` - - You can also expand the scope of packages on a per-command basis via the - [`--scope` filter](/filters#--scope) flag. - +## categories + +Categories are used to group packages together. + +To define custom package categories, add a `categories` section in your `melos.yaml` file. +Under this section, you can specify category names as keys, and their corresponding values +should be lists of glob patterns that match the packages you want to include in each category. + +```yaml +# melos.yaml + +categories: + examples: + - packages/example* + alpha: + - packages/feature_a/* + - packages/feature_b +``` ## ide/intellij diff --git a/docs/filters.mdx b/docs/filters.mdx index d3bc8732..171edd06 100644 --- a/docs/filters.mdx +++ b/docs/filters.mdx @@ -46,6 +46,15 @@ repeated. melos exec --ignore="*internal*" -- flutter build ios ``` +## --categories + +Filter packages based on categories declared in the `melos.yaml` file. + +```bash +# Run `flutter build ios` on all packages in the "examples" category. +melos exec --category="examples" -- flutter build ios +``` + ## --diff Filter packages based on whether there were changes between a commit and the diff --git a/melos.yaml b/melos.yaml index e17f1354..411b2f4e 100644 --- a/melos.yaml +++ b/melos.yaml @@ -5,6 +5,9 @@ packages: - packages/* ignore: - packages/melos_flutter_deps_check +categories: + testando: + - packages/* command: bootstrap: diff --git a/packages/melos/lib/src/command_runner/base.dart b/packages/melos/lib/src/command_runner/base.dart index 9f604d84..48ed0b7c 100644 --- a/packages/melos/lib/src/command_runner/base.dart +++ b/packages/melos/lib/src/command_runner/base.dart @@ -78,6 +78,14 @@ abstract class MelosCommand extends Command { 'option can be repeated.', ); + argParser.addMultiOption( + filterOptionCategory, + valueHelp: 'glob', + help: + 'Include only packages with categories matching the given glob. This ' + 'option can be repeated.', + ); + argParser.addMultiOption( filterOptionIgnore, valueHelp: 'glob', @@ -151,6 +159,7 @@ abstract class MelosCommand extends Command { final diff = diffEnabled ? argResults![filterOptionDiff] as String? : null; final scope = argResults![filterOptionScope] as List? ?? []; + final categories = argResults![filterOptionCategory] as List? ?? []; final ignore = argResults![filterOptionIgnore] as List? ?? []; return PackageFilters( @@ -161,6 +170,9 @@ abstract class MelosCommand extends Command { .map((e) => createGlob(e, currentDirectoryPath: workingDirPath)) .toList() ..addAll(config.ignore), + categories: categories + .map((e) => createGlob(e, currentDirectoryPath: workingDirPath)) + .toList(), diff: diff, includePrivatePackages: argResults![filterOptionPrivate] as bool?, published: argResults![filterOptionPublished] as bool?, diff --git a/packages/melos/lib/src/common/utils.dart b/packages/melos/lib/src/common/utils.dart index bcc8a4ac..bbc75f37 100644 --- a/packages/melos/lib/src/common/utils.dart +++ b/packages/melos/lib/src/common/utils.dart @@ -26,6 +26,7 @@ const globalOptionSdkPath = 'sdk-path'; const autoSdkPathOptionValue = 'auto'; const filterOptionScope = 'scope'; +const filterOptionCategory = 'category'; const filterOptionIgnore = 'ignore'; const filterOptionDirExists = 'dir-exists'; const filterOptionFileExists = 'file-exists'; diff --git a/packages/melos/lib/src/common/validation.dart b/packages/melos/lib/src/common/validation.dart index 688e3538..6d741f14 100644 --- a/packages/melos/lib/src/common/validation.dart +++ b/packages/melos/lib/src/common/validation.dart @@ -89,6 +89,26 @@ List assertListIsA({ ]; } +Map assertMapIsA({ + String? path, + required Object key, + required Map map, + required bool isRequired, + required T Function(Object? value) assertKey, + required V Function(Object? key, Object? value) assertValue, +}) { + final collection = assertKeyIsA?>(key: key, map: map); + + if (isRequired && collection == null) { + throw MelosConfigException.missingKey(key: key, path: path); + } + + return { + for (final entry in collection?.entries ?? >[]) + assertKey(entry.key): assertValue(entry.key, entry.value), + }; +} + /// Thrown when `melos.yaml` configuration is malformed. class MelosConfigException implements MelosException { MelosConfigException(this.message); diff --git a/packages/melos/lib/src/package.dart b/packages/melos/lib/src/package.dart index 2e0fc4de..a300a52f 100644 --- a/packages/melos/lib/src/package.dart +++ b/packages/melos/lib/src/package.dart @@ -101,6 +101,7 @@ class PackageFilters { PackageFilters({ this.scope = const [], this.ignore = const [], + this.categories = const [], this.dirExists = const [], this.fileExists = const [], List dependsOn = const [], @@ -133,6 +134,12 @@ class PackageFilters { path: path, ); + final category = assertListOrString( + key: filterOptionCategory.camelCased, + map: yaml, + path: path, + ); + final ignore = assertListOrString( key: filterOptionIgnore.camelCased, map: yaml, @@ -247,6 +254,7 @@ class PackageFilters { published: published, nullSafe: nullSafe, flutter: flutter, + categories: category.map(createPackageGlob).toList(), ); } @@ -255,6 +263,7 @@ class PackageFilters { const PackageFilters._({ required this.scope, required this.ignore, + required this.categories, required this.dirExists, required this.fileExists, required this.dependsOn, @@ -273,6 +282,9 @@ class PackageFilters { /// Patterns for excluding packages by name. final List ignore; + /// Patterns for filtering packages by category. + final List categories; + /// Include a package only if a given directory exists. final List dirExists; @@ -315,6 +327,9 @@ class PackageFilters { return { if (scope.isNotEmpty) filterOptionScope.camelCased: scope.map((e) => e.toString()).toList(), + if (categories.isNotEmpty) + filterOptionCategory.camelCased: + scope.map((e) => e.toString()).toList(), if (ignore.isNotEmpty) filterOptionIgnore.camelCased: ignore.map((e) => e.toString()).toList(), if (dirExists.isNotEmpty) filterOptionDirExists.camelCased: dirExists, @@ -346,6 +361,7 @@ class PackageFilters { diff: diff, includeDependencies: includeDependencies, includeDependents: includeDependents, + categories: categories, ); } @@ -363,6 +379,7 @@ class PackageFilters { diff: diff, includeDependencies: includeDependencies, includeDependents: includeDependents, + categories: categories, ); } @@ -379,12 +396,14 @@ class PackageFilters { String? diff, bool? includeDependencies, bool? includeDependents, + List? categories, }) { return PackageFilters._( dependsOn: dependsOn ?? this.dependsOn, dirExists: dirExists ?? this.dirExists, fileExists: fileExists ?? this.fileExists, ignore: ignore ?? this.ignore, + categories: categories ?? this.categories, includePrivatePackages: includePrivatePackages ?? this.includePrivatePackages, noDependsOn: noDependsOn ?? this.noDependsOn, @@ -412,6 +431,7 @@ class PackageFilters { const DeepCollectionEquality().equals(other.fileExists, fileExists) && const DeepCollectionEquality().equals(other.dependsOn, dependsOn) && const DeepCollectionEquality().equals(other.noDependsOn, noDependsOn) && + const DeepCollectionEquality().equals(other.categories, categories) && other.diff == diff; @override @@ -428,6 +448,7 @@ class PackageFilters { const DeepCollectionEquality().hash(fileExists) ^ const DeepCollectionEquality().hash(dependsOn) ^ const DeepCollectionEquality().hash(noDependsOn) ^ + const DeepCollectionEquality().hash(categories) ^ diff.hashCode; @override @@ -440,6 +461,7 @@ PackageFilters( includeDependents: $includeDependents, includePrivatePackages: $includePrivatePackages, scope: $scope, + categories: $categories, ignore: $ignore, dirExists: $dirExists, fileExists: $fileExists, @@ -494,6 +516,7 @@ class PackageMap { required String workspacePath, required List packages, required List ignore, + required Map> categories, required MelosLogger logger, }) async { final pubspecFiles = await _resolvePubspecFiles( @@ -528,6 +551,20 @@ The packages that caused the problem are: ); } + final filteredCategories = []; + + categories.forEach((key, value) { + final isCategoryMatching = value.any( + (category) => category.matches( + relativePath(pubspecDirPath, workspacePath), + ), + ); + + if (isCategoryMatching) { + filteredCategories.add(key); + } + }); + packageMap[name] = Package( name: name, path: pubspecDirPath, @@ -539,6 +576,7 @@ The packages that caused the problem are: devDependencies: pubSpec.devDependencies.keys.toList(), dependencyOverrides: pubSpec.dependencyOverrides.keys.toList(), pubSpec: pubSpec, + categories: filteredCategories, ); }), ); @@ -602,6 +640,7 @@ The packages that caused the problem are: .applyFileExists(filters.fileExists) .filterPrivatePackages(include: filters.includePrivatePackages) .applyScope(filters.scope) + .applyCategories(filters.categories) .applyDependsOn(filters.dependsOn) .applyNoDependsOn(filters.noDependsOn) .filterNullSafe(nullSafe: filters.nullSafe) @@ -626,7 +665,7 @@ The packages that caused the problem are: } } -extension on Iterable { +extension IterablePackageExt on Iterable { Iterable applyIgnore(List ignore) { if (ignore.isEmpty) return this; @@ -747,6 +786,18 @@ extension on Iterable { }).toList(); } + Iterable applyCategories(List appliedCategories) { + if (appliedCategories.isEmpty) return this; + + return where((package) { + return package.categories.any( + (category) => appliedCategories.any( + (appliedCategory) => appliedCategory.matches(category), + ), + ); + }).toList(); + } + Iterable applyDependsOn(List dependsOn) { if (dependsOn.isEmpty) return this; @@ -802,6 +853,7 @@ class Package { required this.version, required this.publishTo, required this.pubSpec, + required this.categories, }) : _packageMap = packageMap, assert(p.isAbsolute(path)); @@ -816,6 +868,7 @@ class Package { final Version version; final String path; final PubSpec pubSpec; + final List categories; /// Package path as a normalized sting relative to the root of the workspace. /// e.g. "packages/firebase_database". diff --git a/packages/melos/lib/src/workspace.dart b/packages/melos/lib/src/workspace.dart index d5d8d70f..bfe46a98 100644 --- a/packages/melos/lib/src/workspace.dart +++ b/packages/melos/lib/src/workspace.dart @@ -49,12 +49,14 @@ class MelosWorkspace { workspacePath: workspaceConfig.path, packages: workspaceConfig.packages, ignore: workspaceConfig.ignore, + categories: workspaceConfig.categories, logger: logger, ); final dependencyOverridePackages = await PackageMap.resolvePackages( workspacePath: workspaceConfig.path, packages: workspaceConfig.commands.bootstrap.dependencyOverridePaths, ignore: const [], + categories: const {}, logger: logger, ); diff --git a/packages/melos/lib/src/workspace_configs.dart b/packages/melos/lib/src/workspace_configs.dart index 0ec29478..6e376ef0 100644 --- a/packages/melos/lib/src/workspace_configs.dart +++ b/packages/melos/lib/src/workspace_configs.dart @@ -199,6 +199,7 @@ class MelosWorkspaceConfig { this.sdkPath, this.repository, required this.packages, + this.categories = const {}, this.ignore = const [], this.scripts = Scripts.empty, this.ide = IDEConfigs.empty, @@ -238,14 +239,19 @@ class MelosWorkspaceConfig { map: repositoryYaml, path: 'repository', ); - final name = assertKeyIsA( + final repositoryName = assertKeyIsA( key: 'name', map: repositoryYaml, path: 'repository', ); try { - repository = parseHostedGitRepositorySpec(type, origin, owner, name); + repository = parseHostedGitRepositorySpec( + type, + origin, + owner, + repositoryName, + ); } on FormatException catch (e) { throw MelosConfigException(e.toString()); } @@ -281,6 +287,25 @@ class MelosWorkspaceConfig { path: 'packages', ), ); + + final categories = assertMapIsA>( + key: 'categories', + map: yaml, + isRequired: false, + assertKey: (value) => assertIsA( + value: value, + ), + assertValue: (key, value) => assertListIsA( + key: key!, + map: (yaml['categories'] ?? {}) as Map, + isRequired: false, + assertItemIsA: (index, value) => assertIsA( + value: value, + index: index, + ), + ), + ); + final ignore = assertListIsA( key: 'ignore', map: yaml, @@ -317,6 +342,12 @@ class MelosWorkspaceConfig { name: name, repository: repository, sdkPath: sdkPath, + categories: categories.map( + (key, value) => MapEntry( + key, + value.map(Glob.new).toList(), + ), + ), packages: packages .map((package) => createGlob(package, currentDirectoryPath: path)) .toList(), @@ -465,6 +496,9 @@ class MelosWorkspaceConfig { /// A list of [Glob]s for paths that should be searched for packages. final List packages; + /// A map of [Glob]s for paths that should be searched for packages. + final Map> categories; + /// A list of [Glob]s for paths that should be excluded from the search for /// packages. final List ignore; @@ -547,6 +581,12 @@ class MelosWorkspaceConfig { 'path': path, if (repository != null) 'repository': repository!, 'packages': packages.map((p) => p.toString()).toList(), + 'categories': categories.map((category, packages) { + return MapEntry( + category, + packages.map((p) => p.pattern).toList(), + ); + }), if (ignore.isNotEmpty) 'ignore': ignore.map((p) => p.toString()).toList(), if (scripts.isNotEmpty) 'scripts': scripts.toJson(), 'ide': ide.toJson(), @@ -561,6 +601,7 @@ MelosWorkspaceConfig( path: $path, name: $name, repository: $repository, + categories: $categories, packages: $packages, ignore: $ignore, scripts: ${scripts.toString().indent(' ')}, diff --git a/packages/melos/test/commands/publish_test.dart b/packages/melos/test/commands/publish_test.dart index 21d7833a..6f73e79a 100644 --- a/packages/melos/test/commands/publish_test.dart +++ b/packages/melos/test/commands/publish_test.dart @@ -133,5 +133,6 @@ Package _dummyPackage(String name, {List deps = const []}) { version: Version(1, 0, 0), publishTo: null, pubSpec: const PubSpec(), + categories: [], ); } diff --git a/packages/melos/test/package_filter_test.dart b/packages/melos/test/package_filter_test.dart index ffa0cbd3..5f284762 100644 --- a/packages/melos/test/package_filter_test.dart +++ b/packages/melos/test/package_filter_test.dart @@ -1,5 +1,8 @@ +import 'package:glob/glob.dart'; import 'package:melos/melos.dart'; +import 'package:melos/src/common/glob.dart'; import 'package:melos/src/common/io.dart'; +import 'package:melos/src/common/platform.dart'; import 'package:path/path.dart' as p; import 'package:pubspec/pubspec.dart'; import 'package:test/test.dart'; @@ -79,5 +82,119 @@ void main() { [isA().having((p) => p.name, 'name', 'a')], ); }); + + test('ignore', () async { + final workspaceDir = await createTemporaryWorkspace(); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'b'), + ); + + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final workspace = await MelosWorkspace.fromConfig( + config, + logger: TestLogger().toMelosLogger(), + packageFilters: PackageFilters( + ignore: [Glob('a')], + ), + ); + + expect( + workspace.allPackages.values, + [ + isA().having((p) => p.name, 'name', 'a'), + isA().having((p) => p.name, 'name', 'b'), + ], + ); + expect( + workspace.filteredPackages.values, + [isA().having((p) => p.name, 'name', 'b')], + ); + }); + + test('category', () async { + MelosWorkspaceConfig configBuilder(String path) { + return MelosWorkspaceConfig( + name: 'Melos', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + categories: { + 'a': [ + Glob('packages/a'), + Glob('packages/c'), + ], + 'b': [ + Glob('packages/*a*'), + ], + 'c': [ + Glob('packages/ab*'), + ], + }, + path: currentPlatform.isWindows + ? p.windows.normalize(path).replaceAll(r'\', r'\\') + : path, + ); + } + + final workspaceDir = await createTemporaryWorkspace( + configBuilder: configBuilder, + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + await createProject( + workspaceDir, + const PubSpec(name: 'ab'), + ); + await createProject( + workspaceDir, + const PubSpec(name: 'abc'), + ); + await createProject( + workspaceDir, + const PubSpec(name: 'b'), + ); + await createProject( + workspaceDir, + const PubSpec(name: 'c'), + ); + + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final workspace = await MelosWorkspace.fromConfig( + config, + logger: TestLogger().toMelosLogger(), + packageFilters: PackageFilters( + categories: [Glob('b')], + ), + ); + + expect( + workspace.allPackages.values, + [ + isA().having((p) => p.name, 'name', 'a'), + isA().having((p) => p.name, 'name', 'ab'), + isA().having((p) => p.name, 'name', 'abc'), + isA().having((p) => p.name, 'name', 'b'), + isA().having((p) => p.name, 'name', 'c'), + ], + ); + expect( + workspace.filteredPackages.values, + [ + isA().having((p) => p.name, 'name', 'a'), + isA().having((p) => p.name, 'name', 'ab'), + isA().having((p) => p.name, 'name', 'abc'), + ], + ); + }); }); } diff --git a/packages/melos/test/package_test.dart b/packages/melos/test/package_test.dart index ae8b6fd6..a1667b5a 100644 --- a/packages/melos/test/package_test.dart +++ b/packages/melos/test/package_test.dart @@ -7,6 +7,7 @@ import 'package:melos/src/common/pub_credential.dart'; import 'package:melos/src/package.dart'; import 'package:platform/platform.dart'; import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec/pubspec.dart'; import 'package:test/test.dart'; import 'mock_env.dart'; @@ -239,6 +240,57 @@ void main() { expect(cPackage.allTransitiveDependenciesInWorkspace.keys, ['b']); }); }); + + group('applying filters', () { + test('applyCategory', () { + Package createPackage(String name, List category) { + return Package( + devDependencies: [], + dependencies: [], + dependencyOverrides: [], + packageMap: {}, + name: name, + path: '/test', + pathRelativeToWorkspace: 'test', + version: Version(1, 0, 0), + publishTo: Uri(), + pubSpec: const PubSpec(), + categories: category, + ); + } + + final packages = [ + createPackage('package1', ['ab', 'bc']), + createPackage('package2', ['bc', 'cd']), + createPackage('package3', ['ab', 'cd']), + ]; + + final result1 = packages.applyCategories( + [Glob('ab')], + ); + + expect( + result1, + [ + isA().having((p) => p.name, 'name', 'package1'), + isA().having((p) => p.name, 'name', 'package3'), + ], + ); + + final result2 = packages.applyCategories( + [Glob('*b*')], + ); + + expect( + result2, + [ + isA().having((p) => p.name, 'name', 'package1'), + isA().having((p) => p.name, 'name', 'package2'), + isA().having((p) => p.name, 'name', 'package3'), + ], + ); + }); + }); }); group('PackageFilters', () { @@ -251,6 +303,7 @@ void main() { expect(filters.fileExists, isEmpty); expect(filters.ignore, isEmpty); expect(filters.scope, isEmpty); + expect(filters.categories, isEmpty); expect(filters.includeDependencies, false); expect(filters.includeDependents, false); expect(filters.includePrivatePackages, null); @@ -273,6 +326,7 @@ void main() { fileExists: const ['a'], flutter: true, scope: [Glob('a')], + categories: [Glob('a')], ignore: [Glob('a')], includeDependencies: true, includeDependents: true, @@ -290,6 +344,7 @@ void main() { expect(copy.dirExists, filters.dirExists); expect(copy.fileExists, filters.fileExists); expect(copy.scope, filters.scope); + expect(copy.categories, filters.categories); expect(copy.ignore, filters.ignore); expect(copy.includeDependencies, filters.includeDependencies); expect(copy.includeDependents, filters.includeDependents); diff --git a/packages/melos/test/utils.dart b/packages/melos/test/utils.dart index 7f18b015..b87e2d2a 100644 --- a/packages/melos/test/utils.dart +++ b/packages/melos/test/utils.dart @@ -393,6 +393,7 @@ class VirtualWorkspaceBuilder { dependencyOverrides: pubSpec.dependencyOverrides.keys.toList(), packageMap: packageMap, pathRelativeToWorkspace: pathRelativeToWorkspace, + categories: [], ); }