diff --git a/packages/melos/lib/melos.dart b/packages/melos/lib/melos.dart index faa190f1..38b83f0b 100644 --- a/packages/melos/lib/melos.dart +++ b/packages/melos/lib/melos.dart @@ -1,3 +1,5 @@ +export 'src/command_configs/command_configs.dart' + show BootstrapCommandConfigs, CleanCommandConfigs, VersionCommandConfigs; export 'src/commands/runner.dart' show BootstrapException, @@ -29,10 +31,4 @@ export 'src/package.dart' export 'src/scripts.dart' show ExecOptions, Script, Scripts; export 'src/workspace.dart' show IdeWorkspace, MelosWorkspace; export 'src/workspace_configs.dart' - show - BootstrapCommandConfigs, - CommandConfigs, - IDEConfigs, - IntelliJConfig, - MelosWorkspaceConfig, - VersionCommandConfigs; + show IDEConfigs, IntelliJConfig, MelosWorkspaceConfig; diff --git a/packages/melos/lib/src/command_configs/bootstrap.dart b/packages/melos/lib/src/command_configs/bootstrap.dart new file mode 100644 index 00000000..12121085 --- /dev/null +++ b/packages/melos/lib/src/command_configs/bootstrap.dart @@ -0,0 +1,222 @@ +import 'package:collection/collection.dart'; +import 'package:glob/glob.dart'; +import 'package:meta/meta.dart'; +import 'package:pubspec/pubspec.dart'; + +import '../common/glob.dart'; +import '../common/glob_equality.dart'; +import '../common/utils.dart'; +import '../common/validation.dart'; +import '../lifecycle_hooks/lifecycle_hooks.dart'; + +/// Configurations for `melos bootstrap`. +@immutable +class BootstrapCommandConfigs { + const BootstrapCommandConfigs({ + this.runPubGetInParallel = true, + this.runPubGetOffline = false, + this.enforceLockfile = false, + this.environment, + this.dependencies, + this.devDependencies, + this.dependencyOverridePaths = const [], + this.hooks = LifecycleHooks.empty, + }); + + factory BootstrapCommandConfigs.fromYaml( + Map yaml, { + required String workspacePath, + }) { + final runPubGetInParallel = assertKeyIsA( + key: 'runPubGetInParallel', + map: yaml, + path: 'command/bootstrap', + ) ?? + true; + + final runPubGetOffline = assertKeyIsA( + key: 'runPubGetOffline', + map: yaml, + path: 'command/bootstrap', + ) ?? + false; + + final enforceLockfile = assertKeyIsA( + key: 'enforceLockfile', + map: yaml, + path: 'command/bootstrap', + ) ?? + false; + + final environment = assertKeyIsA?>( + key: 'environment', + map: yaml, + ).let(Environment.fromJson); + + final dependencies = assertKeyIsA?>( + key: 'dependencies', + map: yaml, + )?.map( + (key, value) => MapEntry( + key.toString(), + DependencyReference.fromJson(value), + ), + ); + + final devDependencies = assertKeyIsA?>( + key: 'dev_dependencies', + map: yaml, + )?.map( + (key, value) => MapEntry( + key.toString(), + DependencyReference.fromJson(value), + ), + ); + + final dependencyOverridePaths = assertListIsA( + key: 'dependencyOverridePaths', + map: yaml, + isRequired: false, + assertItemIsA: (index, value) => assertIsA( + value: value, + index: index, + path: 'dependencyOverridePaths', + ), + ); + + final hooksMap = assertKeyIsA?>( + key: 'hooks', + map: yaml, + path: 'command/bootstrap', + ); + final hooks = hooksMap != null + ? LifecycleHooks.fromYaml(hooksMap, workspacePath: workspacePath) + : LifecycleHooks.empty; + + return BootstrapCommandConfigs( + runPubGetInParallel: runPubGetInParallel, + runPubGetOffline: runPubGetOffline, + enforceLockfile: enforceLockfile, + environment: environment, + dependencies: dependencies, + devDependencies: devDependencies, + dependencyOverridePaths: dependencyOverridePaths + .map( + (override) => + createGlob(override, currentDirectoryPath: workspacePath), + ) + .toList(), + hooks: hooks, + ); + } + + static const BootstrapCommandConfigs empty = BootstrapCommandConfigs(); + + /// Whether to run `pub get` in parallel during bootstrapping. + /// + /// The default is `true`. + final bool runPubGetInParallel; + + /// Whether to attempt to run `pub get` in offline mode during bootstrapping. + /// Useful in closed network environments with pre-populated pubcaches. + /// + /// The default is `false`. + final bool runPubGetOffline; + + /// Whether `pubspec.lock` is enforced when running `pub get` or not. + /// Useful when you want to ensure the same versions of dependencies are used + /// across different environments/machines. + /// + /// The default is `false`. + final bool enforceLockfile; + + /// Environment configuration to be synced between all packages. + final Environment? environment; + + /// Dependencies to be synced between all packages. + final Map? dependencies; + + /// Dev dependencies to be synced between all packages. + final Map? devDependencies; + + /// A list of [Glob]s for paths that contain packages to be used as dependency + /// overrides for all packages managed in the Melos workspace. + final List dependencyOverridePaths; + + /// Lifecycle hooks for this command. + final LifecycleHooks hooks; + + Map toJson() { + return { + 'runPubGetInParallel': runPubGetInParallel, + 'runPubGetOffline': runPubGetOffline, + 'enforceLockfile': enforceLockfile, + if (environment != null) 'environment': environment!.toJson(), + if (dependencies != null) + 'dependencies': dependencies!.map( + (key, value) => MapEntry(key, value.toJson()), + ), + if (devDependencies != null) + 'dev_dependencies': devDependencies!.map( + (key, value) => MapEntry(key, value.toJson()), + ), + if (dependencyOverridePaths.isNotEmpty) + 'dependencyOverridePaths': + dependencyOverridePaths.map((path) => path.toString()).toList(), + 'hooks': hooks.toJson(), + }; + } + + @override + bool operator ==(Object other) => + other is BootstrapCommandConfigs && + runtimeType == other.runtimeType && + other.runPubGetInParallel == runPubGetInParallel && + other.runPubGetOffline == runPubGetOffline && + other.enforceLockfile == enforceLockfile && + // Extracting equality from environment here as it does not implement == + other.environment?.sdkConstraint == environment?.sdkConstraint && + const DeepCollectionEquality().equals( + other.environment?.unParsedYaml, + environment?.unParsedYaml, + ) && + const DeepCollectionEquality().equals(other.dependencies, dependencies) && + const DeepCollectionEquality() + .equals(other.devDependencies, devDependencies) && + const DeepCollectionEquality(GlobEquality()) + .equals(other.dependencyOverridePaths, dependencyOverridePaths) && + other.hooks == hooks; + + @override + int get hashCode => + runtimeType.hashCode ^ + runPubGetInParallel.hashCode ^ + runPubGetOffline.hashCode ^ + enforceLockfile.hashCode ^ + // Extracting hashCode from environment here as it does not implement + // hashCode + (environment?.sdkConstraint).hashCode ^ + const DeepCollectionEquality().hash( + environment?.unParsedYaml, + ) ^ + const DeepCollectionEquality().hash(dependencies) ^ + const DeepCollectionEquality().hash(devDependencies) ^ + const DeepCollectionEquality(GlobEquality()) + .hash(dependencyOverridePaths) ^ + hooks.hashCode; + + @override + String toString() { + return ''' +BootstrapCommandConfigs( + runPubGetInParallel: $runPubGetInParallel, + runPubGetOffline: $runPubGetOffline, + enforceLockfile: $enforceLockfile, + environment: $environment, + dependencies: $dependencies, + devDependencies: $devDependencies, + dependencyOverridePaths: $dependencyOverridePaths, + hooks: $hooks, +)'''; + } +} diff --git a/packages/melos/lib/src/command_configs/clean.dart b/packages/melos/lib/src/command_configs/clean.dart new file mode 100644 index 00000000..76bf72d1 --- /dev/null +++ b/packages/melos/lib/src/command_configs/clean.dart @@ -0,0 +1,57 @@ +import 'package:meta/meta.dart'; + +import '../common/validation.dart'; +import '../lifecycle_hooks/lifecycle_hooks.dart'; + +/// Configurations for `melos clean`. +@immutable +class CleanCommandConfigs { + const CleanCommandConfigs({ + this.hooks = LifecycleHooks.empty, + }); + + factory CleanCommandConfigs.fromYaml( + Map yaml, { + required String workspacePath, + }) { + final hooksMap = assertKeyIsA?>( + key: 'hooks', + map: yaml, + path: 'command/clean', + ); + final hooks = hooksMap != null + ? LifecycleHooks.fromYaml(hooksMap, workspacePath: workspacePath) + : LifecycleHooks.empty; + + return CleanCommandConfigs( + hooks: hooks, + ); + } + + static const CleanCommandConfigs empty = CleanCommandConfigs(); + + final LifecycleHooks hooks; + + Map toJson() { + return { + 'hooks': hooks.toJson(), + }; + } + + @override + bool operator ==(Object other) => + other is CleanCommandConfigs && + runtimeType == other.runtimeType && + other.hooks == hooks; + + @override + int get hashCode => runtimeType.hashCode ^ hooks.hashCode; + + @override + String toString() { + return ''' +CleanCommandConfigs( + hooks: $hooks, +)'''; + } +} diff --git a/packages/melos/lib/src/command_configs/command_configs.dart b/packages/melos/lib/src/command_configs/command_configs.dart new file mode 100644 index 00000000..b1fc7a5d --- /dev/null +++ b/packages/melos/lib/src/command_configs/command_configs.dart @@ -0,0 +1,101 @@ +import 'package:meta/meta.dart'; + +import '../common/utils.dart'; +import '../common/validation.dart'; +import 'bootstrap.dart'; +import 'clean.dart'; +import 'version.dart'; + +export 'bootstrap.dart'; +export 'clean.dart'; +export 'version.dart'; + +/// Melos command-specific configurations. +@immutable +class CommandConfigs { + const CommandConfigs({ + this.bootstrap = BootstrapCommandConfigs.empty, + this.clean = CleanCommandConfigs.empty, + this.version = VersionCommandConfigs.empty, + }); + + factory CommandConfigs.fromYaml( + Map yaml, { + required String workspacePath, + bool repositoryIsConfigured = false, + }) { + final bootstrapMap = assertKeyIsA?>( + key: 'bootstrap', + map: yaml, + path: 'command', + ); + + final cleanMap = assertKeyIsA?>( + key: 'clean', + map: yaml, + path: 'command', + ); + + final versionMap = assertKeyIsA?>( + key: 'version', + map: yaml, + path: 'command', + ); + + return CommandConfigs( + bootstrap: BootstrapCommandConfigs.fromYaml( + bootstrapMap ?? const {}, + workspacePath: workspacePath, + ), + clean: CleanCommandConfigs.fromYaml( + cleanMap ?? const {}, + workspacePath: workspacePath, + ), + version: VersionCommandConfigs.fromYaml( + versionMap ?? const {}, + workspacePath: workspacePath, + repositoryIsConfigured: repositoryIsConfigured, + ), + ); + } + + static const CommandConfigs empty = CommandConfigs(); + + final BootstrapCommandConfigs bootstrap; + final CleanCommandConfigs clean; + final VersionCommandConfigs version; + + Map toJson() { + return { + 'bootstrap': bootstrap.toJson(), + 'clean': clean.toJson(), + 'version': version.toJson(), + }; + } + + @override + bool operator ==(Object other) => + other is CommandConfigs && + runtimeType == other.runtimeType && + other.bootstrap == bootstrap && + other.clean == clean && + other.version == version; + + @override + int get hashCode => + runtimeType.hashCode ^ + bootstrap.hashCode ^ + clean.hashCode ^ + version.hashCode; + + @override + String toString() { + return ''' +CommandConfigs( + bootstrap: ${bootstrap.toString().indent(' ')}, + clean: ${clean.toString().indent(' ')}, + version: ${version.toString().indent(' ')}, +) +'''; + } +} diff --git a/packages/melos/lib/src/command_configs/version.dart b/packages/melos/lib/src/command_configs/version.dart new file mode 100644 index 00000000..71cea179 --- /dev/null +++ b/packages/melos/lib/src/command_configs/version.dart @@ -0,0 +1,281 @@ +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import '../../melos.dart'; +import '../common/validation.dart'; +import '../lifecycle_hooks/version.dart'; +import '../workspace_configs.dart'; + +/// Configurations for `melos version`. +@immutable +class VersionCommandConfigs { + const VersionCommandConfigs({ + this.branch, + this.message, + this.includeScopes = true, + this.linkToCommits = false, + this.includeCommitId = false, + this.includeCommitBody = false, + this.commitBodyOnlyBreaking = true, + this.updateGitTagRefs = false, + this.releaseUrl = false, + List? aggregateChangelogs, + this.fetchTags = true, + this.hooks = VersionLifecycleHooks.empty, + }) : _aggregateChangelogs = aggregateChangelogs; + + factory VersionCommandConfigs.fromYaml( + Map yaml, { + required String workspacePath, + bool repositoryIsConfigured = false, + }) { + final branch = assertKeyIsA( + key: 'branch', + map: yaml, + path: 'command/version', + ); + final message = assertKeyIsA( + key: 'message', + map: yaml, + path: 'command/version', + ); + final includeScopes = assertKeyIsA( + key: 'includeScopes', + map: yaml, + path: 'command/version', + ); + final includeCommitId = assertKeyIsA( + key: 'includeCommitId', + map: yaml, + path: 'command/version', + ); + final linkToCommits = assertKeyIsA( + key: 'linkToCommits', + map: yaml, + path: 'command/version', + ); + final updateGitTagRefs = assertKeyIsA( + key: 'updateGitTagRefs', + map: yaml, + path: 'command/version', + ); + final releaseUrl = assertKeyIsA( + key: 'releaseUrl', + map: yaml, + path: 'command/version', + ); + + final workspaceChangelog = assertKeyIsA( + key: 'workspaceChangelog', + map: yaml, + path: 'command/version', + ); + + final aggregateChangelogs = []; + if (workspaceChangelog ?? true) { + aggregateChangelogs.add(AggregateChangelogConfig.workspace()); + } + + final changelogsYaml = assertKeyIsA?>( + key: 'changelogs', + map: yaml, + path: 'command/version', + ); + + if (changelogsYaml != null) { + for (var i = 0; i < changelogsYaml.length; i++) { + final entry = changelogsYaml[i]! as Map; + + final path = assertKeyIsA( + map: entry, + path: 'command/version/changelogs[$i]', + key: 'path', + ); + + final packageFiltersMap = assertKeyIsA>( + map: entry, + key: 'packageFilters', + path: 'command/version/changelogs[$i]', + ); + final packageFilters = PackageFilters.fromYaml( + packageFiltersMap, + path: 'command/version/changelogs[$i]', + workspacePath: workspacePath, + ); + + final description = assertKeyIsA( + map: entry, + path: 'command/version/changelogs[$i]', + key: 'description', + ); + final changelogConfig = AggregateChangelogConfig( + path: path, + packageFilters: packageFilters, + description: description, + ); + + aggregateChangelogs.add(changelogConfig); + } + } + + final fetchTags = assertKeyIsA( + key: 'fetchTags', + map: yaml, + path: 'command/version', + ); + + final hooksMap = assertKeyIsA?>( + key: 'hooks', + map: yaml, + path: 'command/version', + ); + + final hooks = hooksMap != null + ? VersionLifecycleHooks.fromYaml( + hooksMap, + workspacePath: workspacePath, + ) + : VersionLifecycleHooks.empty; + + final changelogCommitBodiesEntry = assertKeyIsA?>( + key: 'changelogCommitBodies', + map: yaml, + path: 'command/version', + ) ?? + const {}; + + final includeCommitBodies = assertKeyIsA( + key: 'include', + map: changelogCommitBodiesEntry, + path: 'command/version/changelogCommitBodies', + ); + + final bodiesOnlyBreaking = assertKeyIsA( + key: 'onlyBreaking', + map: changelogCommitBodiesEntry, + path: 'command/version/changelogCommitBodies', + ); + + return VersionCommandConfigs( + branch: branch, + message: message, + includeScopes: includeScopes ?? true, + includeCommitId: includeCommitId ?? false, + includeCommitBody: includeCommitBodies ?? false, + commitBodyOnlyBreaking: bodiesOnlyBreaking ?? true, + linkToCommits: linkToCommits ?? repositoryIsConfigured, + updateGitTagRefs: updateGitTagRefs ?? false, + releaseUrl: releaseUrl ?? false, + aggregateChangelogs: aggregateChangelogs, + fetchTags: fetchTags ?? true, + hooks: hooks, + ); + } + + static const VersionCommandConfigs empty = VersionCommandConfigs(); + + /// If specified, prevents `melos version` from being used inside branches + /// other than the one specified. + final String? branch; + + /// A custom header for the generated CHANGELOG.md. + final String? message; + + /// Whether to include conventional commit scopes in the generated + /// CHANGELOG.md. + final bool includeScopes; + + /// Whether to add commits ids in the generated CHANGELOG.md. + final bool includeCommitId; + + /// Wheter to include commit bodies in the generated CHANGELOG.md. + final bool includeCommitBody; + + /// Whether to only include commit bodies for breaking changes. + final bool commitBodyOnlyBreaking; + + /// Whether to add links to commits in the generated CHANGELOG.md. + final bool linkToCommits; + + /// Whether to also update pubspec with git referenced packages. + final bool updateGitTagRefs; + + /// Whether to generate and print a link to the prefilled release creation + /// page for each package after versioning. + final bool releaseUrl; + + /// A list of changelogs configurations that will be used to generate + /// changelogs which describe the changes in multiple packages. + List get aggregateChangelogs => + _aggregateChangelogs ?? [AggregateChangelogConfig.workspace()]; + + final List? _aggregateChangelogs; + + /// Whether to fetch tags from the `origin` remote before versioning. + final bool fetchTags; + + /// Lifecycle hooks for this command. + final VersionLifecycleHooks hooks; + + Map toJson() { + return { + if (branch != null) 'branch': branch, + if (message != null) 'message': message, + 'includeScopes': includeScopes, + 'includeCommitId': includeCommitId, + 'linkToCommits': linkToCommits, + 'updateGitTagRefs': updateGitTagRefs, + 'aggregateChangelogs': + aggregateChangelogs.map((config) => config.toJson()).toList(), + 'fetchTags': fetchTags, + 'hooks': hooks.toJson(), + }; + } + + @override + bool operator ==(Object other) => + other is VersionCommandConfigs && + other.runtimeType == runtimeType && + other.branch == branch && + other.message == message && + other.includeScopes == includeScopes && + other.includeCommitId == includeCommitId && + other.linkToCommits == linkToCommits && + other.updateGitTagRefs == updateGitTagRefs && + other.releaseUrl == releaseUrl && + const DeepCollectionEquality() + .equals(other.aggregateChangelogs, aggregateChangelogs) && + other.fetchTags == fetchTags && + other.hooks == hooks; + + @override + int get hashCode => + runtimeType.hashCode ^ + branch.hashCode ^ + message.hashCode ^ + includeScopes.hashCode ^ + includeCommitId.hashCode ^ + linkToCommits.hashCode ^ + updateGitTagRefs.hashCode ^ + releaseUrl.hashCode ^ + const DeepCollectionEquality().hash(aggregateChangelogs) ^ + fetchTags.hashCode ^ + hooks.hashCode; + + @override + String toString() { + return ''' +VersionCommandConfigs( + branch: $branch, + message: $message, + includeScopes: $includeScopes, + includeCommitId: $includeCommitId, + linkToCommits: $linkToCommits, + updateGitTagRefs: $updateGitTagRefs, + releaseUrl: $releaseUrl, + aggregateChangelogs: $aggregateChangelogs, + fetchTags: $fetchTags, + hooks: $hooks, +)'''; + } +} diff --git a/packages/melos/lib/src/commands/runner.dart b/packages/melos/lib/src/commands/runner.dart index 19f2c8b4..7baa31b4 100644 --- a/packages/melos/lib/src/commands/runner.dart +++ b/packages/melos/lib/src/commands/runner.dart @@ -16,6 +16,7 @@ import 'package:pubspec/pubspec.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; +import '../command_configs/command_configs.dart'; import '../command_runner/version.dart'; import '../common/aggregate_changelog.dart'; import '../common/environment_variable_key.dart'; @@ -33,6 +34,7 @@ import '../common/utils.dart'; import '../common/versioning.dart'; import '../common/versioning.dart' as versioning; import '../global_options.dart'; +import '../lifecycle_hooks/lifecycle_hooks.dart'; import '../logging.dart'; import '../package.dart'; import '../scripts.dart'; diff --git a/packages/melos/lib/src/common/glob_equality.dart b/packages/melos/lib/src/common/glob_equality.dart new file mode 100644 index 00000000..c390d380 --- /dev/null +++ b/packages/melos/lib/src/common/glob_equality.dart @@ -0,0 +1,16 @@ +import 'package:collection/collection.dart'; +import 'package:glob/glob.dart'; + +class GlobEquality implements Equality { + const GlobEquality(); + + @override + bool equals(Glob e1, Glob e2) => + e1.pattern == e2.pattern && e1.context.current == e2.context.current; + + @override + int hash(Glob e) => e.pattern.hashCode ^ e.context.current.hashCode; + + @override + bool isValidKey(Object? o) => true; +} diff --git a/packages/melos/lib/src/lifecycle_hooks/lifecycle_hooks.dart b/packages/melos/lib/src/lifecycle_hooks/lifecycle_hooks.dart new file mode 100644 index 00000000..273b1874 --- /dev/null +++ b/packages/melos/lib/src/lifecycle_hooks/lifecycle_hooks.dart @@ -0,0 +1,54 @@ +import 'package:meta/meta.dart'; + +import '../../melos.dart'; + +/// Scripts to be executed before/after a melos command. +@immutable +class LifecycleHooks { + const LifecycleHooks({this.pre, this.post}); + + factory LifecycleHooks.fromYaml( + Map yaml, { + required String workspacePath, + }) { + return LifecycleHooks( + pre: Script.fromName('pre', yaml, workspacePath), + post: Script.fromName('post', yaml, workspacePath), + ); + } + + static const LifecycleHooks empty = LifecycleHooks(); + + /// A script to execute before the melos command starts. + final Script? pre; + + /// A script to execute before the melos command completed. + final Script? post; + + Map toJson() { + return { + 'pre': pre?.toJson(), + 'post': post?.toJson(), + }; + } + + @override + bool operator ==(Object other) => + other is LifecycleHooks && + runtimeType == other.runtimeType && + other.pre == pre && + other.post == post; + + @override + int get hashCode => runtimeType.hashCode ^ pre.hashCode ^ post.hashCode; + + @override + String toString() { + return ''' +LifecycleHooks( + pre: $pre, + post: $post, +) +'''; + } +} diff --git a/packages/melos/lib/src/lifecycle_hooks/version.dart b/packages/melos/lib/src/lifecycle_hooks/version.dart new file mode 100644 index 00000000..ef7c02c8 --- /dev/null +++ b/packages/melos/lib/src/lifecycle_hooks/version.dart @@ -0,0 +1,58 @@ +import 'package:meta/meta.dart'; + +import '../../melos.dart'; +import 'lifecycle_hooks.dart'; + +/// [LifecycleHooks] for the `version` command. +@immutable +class VersionLifecycleHooks extends LifecycleHooks { + const VersionLifecycleHooks({super.pre, super.post, this.preCommit}); + + factory VersionLifecycleHooks.fromYaml( + Map yaml, { + required String workspacePath, + }) { + return VersionLifecycleHooks( + pre: Script.fromName('pre', yaml, workspacePath), + post: Script.fromName('post', yaml, workspacePath), + preCommit: Script.fromName('preCommit', yaml, workspacePath), + ); + } + + /// A script to execute before the version command commits the the changes + /// made during versioning. + final Script? preCommit; + + static const VersionLifecycleHooks empty = VersionLifecycleHooks(); + + @override + Map toJson() { + return { + ...super.toJson(), + 'preCommit': preCommit?.toJson(), + }; + } + + @override + bool operator ==(Object other) => + other is VersionLifecycleHooks && + runtimeType == other.runtimeType && + other.pre == pre && + other.post == post && + other.preCommit == preCommit; + + @override + int get hashCode => + runtimeType.hashCode ^ pre.hashCode ^ post.hashCode ^ preCommit.hashCode; + + @override + String toString() { + return ''' +VersionLifecycleHooks( + pre: $pre, + post: $post, + preCommit: $preCommit, +) +'''; + } +} diff --git a/packages/melos/lib/src/scripts.dart b/packages/melos/lib/src/scripts.dart index 17f32430..d7a1bc91 100644 --- a/packages/melos/lib/src/scripts.dart +++ b/packages/melos/lib/src/scripts.dart @@ -217,6 +217,19 @@ class Script { ); } + @internal + static Script? fromName( + String name, + Map yaml, + String workspacePath, + ) { + final script = yaml[name]; + if (script == null) { + return null; + } + return Script.fromYaml(script, name: name, workspacePath: workspacePath); + } + @visibleForTesting static ExecOptions execOptionsFromYaml( Map yaml, { diff --git a/packages/melos/lib/src/workspace.dart b/packages/melos/lib/src/workspace.dart index dbc0468b..b3cc0380 100644 --- a/packages/melos/lib/src/workspace.dart +++ b/packages/melos/lib/src/workspace.dart @@ -20,6 +20,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; +import 'command_configs/command_configs.dart'; import 'common/environment_variable_key.dart'; import 'common/intellij_project.dart'; import 'common/io.dart'; diff --git a/packages/melos/lib/src/workspace_configs.dart b/packages/melos/lib/src/workspace_configs.dart index 58e0aaf3..474550e3 100644 --- a/packages/melos/lib/src/workspace_configs.dart +++ b/packages/melos/lib/src/workspace_configs.dart @@ -21,12 +21,13 @@ import 'package:ansi_styles/ansi_styles.dart'; import 'package:collection/collection.dart'; import 'package:glob/glob.dart'; import 'package:meta/meta.dart'; -import 'package:pubspec/pubspec.dart'; import 'package:yaml/yaml.dart'; import '../melos.dart'; +import 'command_configs/command_configs.dart'; import 'common/git_repository.dart'; import 'common/glob.dart'; +import 'common/glob_equality.dart'; import 'common/io.dart'; import 'common/utils.dart'; import 'common/validation.dart'; @@ -144,752 +145,6 @@ IntelliJConfig( } } -/// Melos command-specific configurations. -@immutable -class CommandConfigs { - const CommandConfigs({ - this.bootstrap = BootstrapCommandConfigs.empty, - this.clean = CleanCommandConfigs.empty, - this.version = VersionCommandConfigs.empty, - }); - - factory CommandConfigs.fromYaml( - Map yaml, { - required String workspacePath, - bool repositoryIsConfigured = false, - }) { - final bootstrapMap = assertKeyIsA?>( - key: 'bootstrap', - map: yaml, - path: 'command', - ); - - final cleanMap = assertKeyIsA?>( - key: 'clean', - map: yaml, - path: 'command', - ); - - final versionMap = assertKeyIsA?>( - key: 'version', - map: yaml, - path: 'command', - ); - - return CommandConfigs( - bootstrap: BootstrapCommandConfigs.fromYaml( - bootstrapMap ?? const {}, - workspacePath: workspacePath, - ), - clean: CleanCommandConfigs.fromYaml( - cleanMap ?? const {}, - workspacePath: workspacePath, - ), - version: VersionCommandConfigs.fromYaml( - versionMap ?? const {}, - workspacePath: workspacePath, - repositoryIsConfigured: repositoryIsConfigured, - ), - ); - } - - static const CommandConfigs empty = CommandConfigs(); - - final BootstrapCommandConfigs bootstrap; - final CleanCommandConfigs clean; - final VersionCommandConfigs version; - - Map toJson() { - return { - 'bootstrap': bootstrap.toJson(), - 'clean': clean.toJson(), - 'version': version.toJson(), - }; - } - - @override - bool operator ==(Object other) => - other is CommandConfigs && - runtimeType == other.runtimeType && - other.bootstrap == bootstrap && - other.clean == clean && - other.version == version; - - @override - int get hashCode => - runtimeType.hashCode ^ - bootstrap.hashCode ^ - clean.hashCode ^ - version.hashCode; - - @override - String toString() { - return ''' -CommandConfigs( - bootstrap: ${bootstrap.toString().indent(' ')}, - clean: ${clean.toString().indent(' ')}, - version: ${version.toString().indent(' ')}, -) -'''; - } -} - -/// Scripts to be executed before/after a melos command. -@immutable -class LifecycleHooks { - const LifecycleHooks({this.pre, this.post}); - - factory LifecycleHooks.fromYaml( - Map yaml, { - required String workspacePath, - }) { - return LifecycleHooks( - pre: LifecycleHooks._namedScript('pre', yaml, workspacePath), - post: LifecycleHooks._namedScript('post', yaml, workspacePath), - ); - } - - static Script? _namedScript( - String name, - Map yaml, - String workspacePath, - ) { - final script = yaml[name]; - if (script == null) { - return null; - } - return Script.fromYaml(script, name: name, workspacePath: workspacePath); - } - - static const LifecycleHooks empty = LifecycleHooks(); - - /// A script to execute before the melos command starts. - final Script? pre; - - /// A script to execute before the melos command completed. - final Script? post; - - Map toJson() { - return { - 'pre': pre?.toJson(), - 'post': post?.toJson(), - }; - } - - @override - bool operator ==(Object other) => - other is LifecycleHooks && - runtimeType == other.runtimeType && - other.pre == pre && - other.post == post; - - @override - int get hashCode => runtimeType.hashCode ^ pre.hashCode ^ post.hashCode; - - @override - String toString() { - return ''' -LifecycleHooks( - pre: $pre, - post: $post, -) -'''; - } -} - -/// [LifecycleHooks] for the `version` command. -@immutable -class VersionLifecycleHooks extends LifecycleHooks { - const VersionLifecycleHooks({super.pre, super.post, this.preCommit}); - - factory VersionLifecycleHooks.fromYaml( - Map yaml, { - required String workspacePath, - }) { - return VersionLifecycleHooks( - pre: LifecycleHooks._namedScript('pre', yaml, workspacePath), - post: LifecycleHooks._namedScript('post', yaml, workspacePath), - preCommit: LifecycleHooks._namedScript('preCommit', yaml, workspacePath), - ); - } - - /// A script to execute before the version command commits the the changes - /// made during versioning. - final Script? preCommit; - - static const VersionLifecycleHooks empty = VersionLifecycleHooks(); - - @override - Map toJson() { - return { - ...super.toJson(), - 'preCommit': preCommit?.toJson(), - }; - } - - @override - bool operator ==(Object other) => - other is VersionLifecycleHooks && - runtimeType == other.runtimeType && - other.pre == pre && - other.post == post && - other.preCommit == preCommit; - - @override - int get hashCode => - runtimeType.hashCode ^ pre.hashCode ^ post.hashCode ^ preCommit.hashCode; - - @override - String toString() { - return ''' -VersionLifecycleHooks( - pre: $pre, - post: $post, - preCommit: $preCommit, -) -'''; - } -} - -/// Configurations for `melos bootstrap`. -@immutable -class BootstrapCommandConfigs { - const BootstrapCommandConfigs({ - this.runPubGetInParallel = true, - this.runPubGetOffline = false, - this.enforceLockfile = false, - this.environment, - this.dependencies, - this.devDependencies, - this.dependencyOverridePaths = const [], - this.hooks = LifecycleHooks.empty, - }); - - factory BootstrapCommandConfigs.fromYaml( - Map yaml, { - required String workspacePath, - }) { - final runPubGetInParallel = assertKeyIsA( - key: 'runPubGetInParallel', - map: yaml, - path: 'command/bootstrap', - ) ?? - true; - - final runPubGetOffline = assertKeyIsA( - key: 'runPubGetOffline', - map: yaml, - path: 'command/bootstrap', - ) ?? - false; - - final enforceLockfile = assertKeyIsA( - key: 'enforceLockfile', - map: yaml, - path: 'command/bootstrap', - ) ?? - false; - - final environment = assertKeyIsA?>( - key: 'environment', - map: yaml, - ).let(Environment.fromJson); - - final dependencies = assertKeyIsA?>( - key: 'dependencies', - map: yaml, - )?.map( - (key, value) => MapEntry( - key.toString(), - DependencyReference.fromJson(value), - ), - ); - - final devDependencies = assertKeyIsA?>( - key: 'dev_dependencies', - map: yaml, - )?.map( - (key, value) => MapEntry( - key.toString(), - DependencyReference.fromJson(value), - ), - ); - - final dependencyOverridePaths = assertListIsA( - key: 'dependencyOverridePaths', - map: yaml, - isRequired: false, - assertItemIsA: (index, value) => assertIsA( - value: value, - index: index, - path: 'dependencyOverridePaths', - ), - ); - - final hooksMap = assertKeyIsA?>( - key: 'hooks', - map: yaml, - path: 'command/bootstrap', - ); - final hooks = hooksMap != null - ? LifecycleHooks.fromYaml(hooksMap, workspacePath: workspacePath) - : LifecycleHooks.empty; - - return BootstrapCommandConfigs( - runPubGetInParallel: runPubGetInParallel, - runPubGetOffline: runPubGetOffline, - enforceLockfile: enforceLockfile, - environment: environment, - dependencies: dependencies, - devDependencies: devDependencies, - dependencyOverridePaths: dependencyOverridePaths - .map( - (override) => - createGlob(override, currentDirectoryPath: workspacePath), - ) - .toList(), - hooks: hooks, - ); - } - - static const BootstrapCommandConfigs empty = BootstrapCommandConfigs(); - - /// Whether to run `pub get` in parallel during bootstrapping. - /// - /// The default is `true`. - final bool runPubGetInParallel; - - /// Whether to attempt to run `pub get` in offline mode during bootstrapping. - /// Useful in closed network environments with pre-populated pubcaches. - /// - /// The default is `false`. - final bool runPubGetOffline; - - /// Whether `pubspec.lock` is enforced when running `pub get` or not. - /// Useful when you want to ensure the same versions of dependencies are used - /// across different environments/machines. - /// - /// The default is `false`. - final bool enforceLockfile; - - /// Environment configuration to be synced between all packages. - final Environment? environment; - - /// Dependencies to be synced between all packages. - final Map? dependencies; - - /// Dev dependencies to be synced between all packages. - final Map? devDependencies; - - /// A list of [Glob]s for paths that contain packages to be used as dependency - /// overrides for all packages managed in the Melos workspace. - final List dependencyOverridePaths; - - /// Lifecycle hooks for this command. - final LifecycleHooks hooks; - - Map toJson() { - return { - 'runPubGetInParallel': runPubGetInParallel, - 'runPubGetOffline': runPubGetOffline, - 'enforceLockfile': enforceLockfile, - if (environment != null) 'environment': environment!.toJson(), - if (dependencies != null) - 'dependencies': dependencies!.map( - (key, value) => MapEntry(key, value.toJson()), - ), - if (devDependencies != null) - 'dev_dependencies': devDependencies!.map( - (key, value) => MapEntry(key, value.toJson()), - ), - if (dependencyOverridePaths.isNotEmpty) - 'dependencyOverridePaths': - dependencyOverridePaths.map((path) => path.toString()).toList(), - 'hooks': hooks.toJson(), - }; - } - - @override - bool operator ==(Object other) => - other is BootstrapCommandConfigs && - runtimeType == other.runtimeType && - other.runPubGetInParallel == runPubGetInParallel && - other.runPubGetOffline == runPubGetOffline && - other.enforceLockfile == enforceLockfile && - // Extracting equality from environment here as it does not implement == - other.environment?.sdkConstraint == environment?.sdkConstraint && - const DeepCollectionEquality().equals( - other.environment?.unParsedYaml, - environment?.unParsedYaml, - ) && - const DeepCollectionEquality().equals(other.dependencies, dependencies) && - const DeepCollectionEquality() - .equals(other.devDependencies, devDependencies) && - const DeepCollectionEquality(_GlobEquality()) - .equals(other.dependencyOverridePaths, dependencyOverridePaths) && - other.hooks == hooks; - - @override - int get hashCode => - runtimeType.hashCode ^ - runPubGetInParallel.hashCode ^ - runPubGetOffline.hashCode ^ - enforceLockfile.hashCode ^ - // Extracting hashCode from environment here as it does not implement - // hashCode - (environment?.sdkConstraint).hashCode ^ - const DeepCollectionEquality().hash( - environment?.unParsedYaml, - ) ^ - const DeepCollectionEquality().hash(dependencies) ^ - const DeepCollectionEquality().hash(devDependencies) ^ - const DeepCollectionEquality(_GlobEquality()) - .hash(dependencyOverridePaths) ^ - hooks.hashCode; - - @override - String toString() { - return ''' -BootstrapCommandConfigs( - runPubGetInParallel: $runPubGetInParallel, - runPubGetOffline: $runPubGetOffline, - enforceLockfile: $enforceLockfile, - environment: $environment, - dependencies: $dependencies, - devDependencies: $devDependencies, - dependencyOverridePaths: $dependencyOverridePaths, - hooks: $hooks, -)'''; - } -} - -/// Configurations for `melos clean`. -@immutable -class CleanCommandConfigs { - const CleanCommandConfigs({ - this.hooks = LifecycleHooks.empty, - }); - - factory CleanCommandConfigs.fromYaml( - Map yaml, { - required String workspacePath, - }) { - final hooksMap = assertKeyIsA?>( - key: 'hooks', - map: yaml, - path: 'command/clean', - ); - final hooks = hooksMap != null - ? LifecycleHooks.fromYaml(hooksMap, workspacePath: workspacePath) - : LifecycleHooks.empty; - - return CleanCommandConfigs( - hooks: hooks, - ); - } - - static const CleanCommandConfigs empty = CleanCommandConfigs(); - - final LifecycleHooks hooks; - - Map toJson() { - return { - 'hooks': hooks.toJson(), - }; - } - - @override - bool operator ==(Object other) => - other is CleanCommandConfigs && - runtimeType == other.runtimeType && - other.hooks == hooks; - - @override - int get hashCode => runtimeType.hashCode ^ hooks.hashCode; - - @override - String toString() { - return ''' -CleanCommandConfigs( - hooks: $hooks, -)'''; - } -} - -/// Configurations for `melos version`. -@immutable -class VersionCommandConfigs { - const VersionCommandConfigs({ - this.branch, - this.message, - this.includeScopes = true, - this.linkToCommits = false, - this.includeCommitId = false, - this.includeCommitBody = false, - this.commitBodyOnlyBreaking = true, - this.updateGitTagRefs = false, - this.releaseUrl = false, - List? aggregateChangelogs, - this.fetchTags = true, - this.hooks = VersionLifecycleHooks.empty, - }) : _aggregateChangelogs = aggregateChangelogs; - - factory VersionCommandConfigs.fromYaml( - Map yaml, { - required String workspacePath, - bool repositoryIsConfigured = false, - }) { - final branch = assertKeyIsA( - key: 'branch', - map: yaml, - path: 'command/version', - ); - final message = assertKeyIsA( - key: 'message', - map: yaml, - path: 'command/version', - ); - final includeScopes = assertKeyIsA( - key: 'includeScopes', - map: yaml, - path: 'command/version', - ); - final includeCommitId = assertKeyIsA( - key: 'includeCommitId', - map: yaml, - path: 'command/version', - ); - final linkToCommits = assertKeyIsA( - key: 'linkToCommits', - map: yaml, - path: 'command/version', - ); - final updateGitTagRefs = assertKeyIsA( - key: 'updateGitTagRefs', - map: yaml, - path: 'command/version', - ); - final releaseUrl = assertKeyIsA( - key: 'releaseUrl', - map: yaml, - path: 'command/version', - ); - - final workspaceChangelog = assertKeyIsA( - key: 'workspaceChangelog', - map: yaml, - path: 'command/version', - ); - - final aggregateChangelogs = []; - if (workspaceChangelog ?? true) { - aggregateChangelogs.add(AggregateChangelogConfig.workspace()); - } - - final changelogsYaml = assertKeyIsA?>( - key: 'changelogs', - map: yaml, - path: 'command/version', - ); - - if (changelogsYaml != null) { - for (var i = 0; i < changelogsYaml.length; i++) { - final entry = changelogsYaml[i]! as Map; - - final path = assertKeyIsA( - map: entry, - path: 'command/version/changelogs[$i]', - key: 'path', - ); - - final packageFiltersMap = assertKeyIsA>( - map: entry, - key: 'packageFilters', - path: 'command/version/changelogs[$i]', - ); - final packageFilters = PackageFilters.fromYaml( - packageFiltersMap, - path: 'command/version/changelogs[$i]', - workspacePath: workspacePath, - ); - - final description = assertKeyIsA( - map: entry, - path: 'command/version/changelogs[$i]', - key: 'description', - ); - final changelogConfig = AggregateChangelogConfig( - path: path, - packageFilters: packageFilters, - description: description, - ); - - aggregateChangelogs.add(changelogConfig); - } - } - - final fetchTags = assertKeyIsA( - key: 'fetchTags', - map: yaml, - path: 'command/version', - ); - - final hooksMap = assertKeyIsA?>( - key: 'hooks', - map: yaml, - path: 'command/version', - ); - - final hooks = hooksMap != null - ? VersionLifecycleHooks.fromYaml( - hooksMap, - workspacePath: workspacePath, - ) - : VersionLifecycleHooks.empty; - - final changelogCommitBodiesEntry = assertKeyIsA?>( - key: 'changelogCommitBodies', - map: yaml, - path: 'command/version', - ) ?? - const {}; - - final includeCommitBodies = assertKeyIsA( - key: 'include', - map: changelogCommitBodiesEntry, - path: 'command/version/changelogCommitBodies', - ); - - final bodiesOnlyBreaking = assertKeyIsA( - key: 'onlyBreaking', - map: changelogCommitBodiesEntry, - path: 'command/version/changelogCommitBodies', - ); - - return VersionCommandConfigs( - branch: branch, - message: message, - includeScopes: includeScopes ?? true, - includeCommitId: includeCommitId ?? false, - includeCommitBody: includeCommitBodies ?? false, - commitBodyOnlyBreaking: bodiesOnlyBreaking ?? true, - linkToCommits: linkToCommits ?? repositoryIsConfigured, - updateGitTagRefs: updateGitTagRefs ?? false, - releaseUrl: releaseUrl ?? false, - aggregateChangelogs: aggregateChangelogs, - fetchTags: fetchTags ?? true, - hooks: hooks, - ); - } - - static const VersionCommandConfigs empty = VersionCommandConfigs(); - - /// If specified, prevents `melos version` from being used inside branches - /// other than the one specified. - final String? branch; - - /// A custom header for the generated CHANGELOG.md. - final String? message; - - /// Whether to include conventional commit scopes in the generated - /// CHANGELOG.md. - final bool includeScopes; - - /// Whether to add commits ids in the generated CHANGELOG.md. - final bool includeCommitId; - - /// Wheter to include commit bodies in the generated CHANGELOG.md. - final bool includeCommitBody; - - /// Whether to only include commit bodies for breaking changes. - final bool commitBodyOnlyBreaking; - - /// Whether to add links to commits in the generated CHANGELOG.md. - final bool linkToCommits; - - /// Whether to also update pubspec with git referenced packages. - final bool updateGitTagRefs; - - /// Whether to generate and print a link to the prefilled release creation - /// page for each package after versioning. - final bool releaseUrl; - - /// A list of changelogs configurations that will be used to generate - /// changelogs which describe the changes in multiple packages. - List get aggregateChangelogs => - _aggregateChangelogs ?? [AggregateChangelogConfig.workspace()]; - - final List? _aggregateChangelogs; - - /// Whether to fetch tags from the `origin` remote before versioning. - final bool fetchTags; - - /// Lifecycle hooks for this command. - final VersionLifecycleHooks hooks; - - Map toJson() { - return { - if (branch != null) 'branch': branch, - if (message != null) 'message': message, - 'includeScopes': includeScopes, - 'includeCommitId': includeCommitId, - 'linkToCommits': linkToCommits, - 'updateGitTagRefs': updateGitTagRefs, - 'aggregateChangelogs': - aggregateChangelogs.map((config) => config.toJson()).toList(), - 'fetchTags': fetchTags, - 'hooks': hooks.toJson(), - }; - } - - @override - bool operator ==(Object other) => - other is VersionCommandConfigs && - other.runtimeType == runtimeType && - other.branch == branch && - other.message == message && - other.includeScopes == includeScopes && - other.includeCommitId == includeCommitId && - other.linkToCommits == linkToCommits && - other.updateGitTagRefs == updateGitTagRefs && - other.releaseUrl == releaseUrl && - const DeepCollectionEquality() - .equals(other.aggregateChangelogs, aggregateChangelogs) && - other.fetchTags == fetchTags && - other.hooks == hooks; - - @override - int get hashCode => - runtimeType.hashCode ^ - branch.hashCode ^ - message.hashCode ^ - includeScopes.hashCode ^ - includeCommitId.hashCode ^ - linkToCommits.hashCode ^ - updateGitTagRefs.hashCode ^ - releaseUrl.hashCode ^ - const DeepCollectionEquality().hash(aggregateChangelogs) ^ - fetchTags.hashCode ^ - hooks.hashCode; - - @override - String toString() { - return ''' -VersionCommandConfigs( - branch: $branch, - message: $message, - includeScopes: $includeScopes, - includeCommitId: $includeCommitId, - linkToCommits: $linkToCommits, - updateGitTagRefs: $updateGitTagRefs, - releaseUrl: $releaseUrl, - aggregateChangelogs: $aggregateChangelogs, - fetchTags: $fetchTags, - hooks: $hooks, -)'''; - } -} - @immutable class AggregateChangelogConfig { const AggregateChangelogConfig({ @@ -1283,9 +538,9 @@ class MelosWorkspaceConfig { other.path == path && other.name == name && other.repository == repository && - const DeepCollectionEquality(_GlobEquality()) + const DeepCollectionEquality(GlobEquality()) .equals(other.packages, packages) && - const DeepCollectionEquality(_GlobEquality()) + const DeepCollectionEquality(GlobEquality()) .equals(other.ignore, ignore) && other.scripts == scripts && other.ide == ide && @@ -1297,8 +552,8 @@ class MelosWorkspaceConfig { path.hashCode ^ name.hashCode ^ repository.hashCode ^ - const DeepCollectionEquality(_GlobEquality()).hash(packages) & - const DeepCollectionEquality(_GlobEquality()).hash(ignore) ^ + const DeepCollectionEquality(GlobEquality()).hash(packages) & + const DeepCollectionEquality(GlobEquality()).hash(ignore) ^ scripts.hashCode ^ ide.hashCode ^ commands.hashCode; @@ -1332,20 +587,6 @@ MelosWorkspaceConfig( } } -class _GlobEquality implements Equality { - const _GlobEquality(); - - @override - bool equals(Glob e1, Glob e2) => - e1.pattern == e2.pattern && e1.context.current == e2.context.current; - - @override - int hash(Glob e) => e.pattern.hashCode ^ e.context.current.hashCode; - - @override - bool isValidKey(Object? o) => true; -} - /// An exception thrown when a Melos workspace could not be resolved. class UnresolvedWorkspace implements MelosException { UnresolvedWorkspace(this.message); diff --git a/packages/melos/test/commands/bootstrap_test.dart b/packages/melos/test/commands/bootstrap_test.dart index c9012107..5a6a82b9 100644 --- a/packages/melos/test/commands/bootstrap_test.dart +++ b/packages/melos/test/commands/bootstrap_test.dart @@ -1,6 +1,7 @@ import 'dart:io' as io; import 'package:melos/melos.dart'; +import 'package:melos/src/command_configs/command_configs.dart'; import 'package:melos/src/commands/runner.dart'; import 'package:melos/src/common/glob.dart'; import 'package:melos/src/common/utils.dart'; diff --git a/packages/melos/test/workspace_config_test.dart b/packages/melos/test/workspace_config_test.dart index bdb677e5..9ec28bd0 100644 --- a/packages/melos/test/workspace_config_test.dart +++ b/packages/melos/test/workspace_config_test.dart @@ -15,6 +15,7 @@ */ import 'package:melos/melos.dart'; +import 'package:melos/src/command_configs/command_configs.dart'; import 'package:melos/src/common/git_repository.dart'; import 'package:melos/src/common/glob.dart'; import 'package:melos/src/common/platform.dart';