From b8da256b5e03c92f6c14d9d334a277856a52bcdb Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Thu, 1 Jun 2023 17:14:31 -0400 Subject: [PATCH] feat: Give cutler some sane default behavior (#578) --- packages/cutler/analysis_options.yaml | 2 +- packages/cutler/bin/cutler.dart | 65 +++++++++++++++---- packages/cutler/lib/commands/base.dart | 13 ++++ .../lib/commands/print_versions_command.dart | 32 +++++++-- .../cutler/lib/commands/rebase_command.dart | 16 +++-- packages/cutler/lib/config.dart | 16 +++++ packages/cutler/lib/git_extensions.dart | 15 +++++ packages/cutler/lib/model.dart | 39 +++++++++++ packages/cutler/lib/versions.dart | 8 ++- packages/cutler/pubspec.lock | 8 +++ packages/cutler/pubspec.yaml | 1 + 11 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 packages/cutler/lib/commands/base.dart diff --git a/packages/cutler/analysis_options.yaml b/packages/cutler/analysis_options.yaml index 0e6ebf74..6fcb3d3b 100644 --- a/packages/cutler/analysis_options.yaml +++ b/packages/cutler/analysis_options.yaml @@ -1,5 +1,5 @@ include: package:very_good_analysis/analysis_options.5.0.0.yaml linter: rules: - public_member_api_docs: false + # avoid_print can be removed now that we have a logger. avoid_print: false diff --git a/packages/cutler/bin/cutler.dart b/packages/cutler/bin/cutler.dart index 70b725e4..9cd4314d 100644 --- a/packages/cutler/bin/cutler.dart +++ b/packages/cutler/bin/cutler.dart @@ -5,17 +5,20 @@ import 'package:args/command_runner.dart'; import 'package:cutler/commands/commands.dart'; import 'package:cutler/config.dart'; import 'package:cutler/model.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; class Cutler extends CommandRunner { - Cutler() : super('cutler', 'A tool for maintaining forks of Flutter.') { - addCommand(RebaseCommand()); - addCommand(PrintVersionsCommand()); + Cutler({Logger? logger}) + : _logger = logger ?? Logger(), + super('cutler', 'A tool for maintaining forks of Flutter.') { + addCommand(RebaseCommand(logger: _logger)); + addCommand(PrintVersionsCommand(logger: _logger)); argParser ..addFlag('verbose', abbr: 'v') ..addOption( 'root', - defaultsTo: '.', help: 'Directory in which to find checkouts.', ) ..addOption( @@ -27,24 +30,60 @@ class Cutler extends CommandRunner { ..addFlag('update', defaultsTo: true, help: 'Update checkouts.'); } + final Logger _logger; + + Iterable missingDirectories(String rootDir) { + return Repo.values.map((repo) => '$rootDir/${repo.path}').where( + (path) => !Directory(path).existsSync(), + ); + } + + String fallbackRootDir() { + final cutlerBin = p.dirname(Platform.script.path); + final cutlerRoot = p.dirname(cutlerBin); + final packagesDir = p.dirname(cutlerRoot); + final shorebirdDir = p.dirname(packagesDir); + final fallbackDirectories = [ + Directory.current.path, + p.dirname(shorebirdDir), + // Internal checkouts use a _shorebird wrapper directory. + p.dirname(p.dirname(shorebirdDir)), + ]; + for (final directory in fallbackDirectories) { + if (missingDirectories(directory).isEmpty) { + print('Using $directory as checkouts root.'); + return directory; + } + } + _logger.err('Failed to find a valid checkouts root, tried:\n' + '${fallbackDirectories.join('\n')}'); + return ''; // Returning an invalid directory will cause validation to fail. + } + @override ArgResults parse(Iterable args) { final results = super.parse(args); + + final rootDir = results['root'] as String? ?? fallbackRootDir(); + + final missingDirs = missingDirectories(rootDir); + if (missingDirs.isNotEmpty) { + _logger + ..err('Could not find a valid checkouts root.') + ..err('--root must be a directory containing the ' + 'following:\n${Repo.values.map((r) => r.path).join('\n')}') + ..err('Missing directories:\n${missingDirs.join('\n')}'); + exit(1); + } + config = Config( - checkoutsRoot: expandUser(results['root'] as String), + checkoutsRoot: expandUser(rootDir), verbose: results['verbose'] as bool, dryRun: results['dry-run'] as bool, doUpdate: results['update'] as bool, flutterChannel: results['flutter-channel'] as String, ); - for (final repo in Repo.values) { - final path = '${config.checkoutsRoot}/${repo.path}'; - if (!Directory(path).existsSync()) { - throw Exception( - 'Directory $path does not exist, are you sure --root is correct?', - ); - } - } + return results; } } diff --git a/packages/cutler/lib/commands/base.dart b/packages/cutler/lib/commands/base.dart new file mode 100644 index 00000000..8ede23e3 --- /dev/null +++ b/packages/cutler/lib/commands/base.dart @@ -0,0 +1,13 @@ +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; + +/// Base class for Cutler subcommands. +abstract class CutlerCommand extends Command { + /// Constructs a new [CutlerCommand]. + CutlerCommand({ + required this.logger, + }); + + /// The logger to use for this command. + final Logger logger; +} diff --git a/packages/cutler/lib/commands/print_versions_command.dart b/packages/cutler/lib/commands/print_versions_command.dart index 6e858776..770f4388 100644 --- a/packages/cutler/lib/commands/print_versions_command.dart +++ b/packages/cutler/lib/commands/print_versions_command.dart @@ -1,11 +1,13 @@ -import 'package:args/command_runner.dart'; +import 'package:cutler/commands/base.dart'; import 'package:cutler/git_extensions.dart'; import 'package:cutler/model.dart'; import 'package:cutler/versions.dart'; import 'package:io/io.dart'; -class PrintVersionsCommand extends Command { - PrintVersionsCommand(); +/// Print the versions a given Shorebird release hash depends on. +class PrintVersionsCommand extends CutlerCommand { + /// Constructs a new [PrintVersionsCommand] with a given [logger]. + PrintVersionsCommand({required super.logger}); @override final name = 'print-versions'; @override @@ -14,12 +16,30 @@ class PrintVersionsCommand extends Command { @override int run() { - final shorebirdHash = argResults!.rest.first; + late final String shorebirdHash; + if (argResults!.rest.isEmpty) { + print('No Shorebird hash provided, using `origin/stable`.'); + shorebirdHash = 'origin/stable'; + } else { + shorebirdHash = argResults!.rest.first; + } + final shorebirdFlutter = Repo.shorebird .contentsAtPath(shorebirdHash, 'bin/internal/flutter.version'); final shorebird = getFlutterVersions(shorebirdFlutter); - print('Shorebird $shorebirdHash:'); - printVersions(shorebird, 2); + logger.info('Shorebird $shorebirdHash:'); + printVersions(shorebird, indent: 2); + + final flutterForkpoint = Repo.flutter.getForkPoint(shorebird.flutter.hash); + // This is slightly error-prone in that we're assuming that our engine and + // buildroot forks started from the correct commit. But I'm not sure how + // to determine the forkpoint otherwise. engine and buildroot don't have + // a stable branch, yet they do seem to "branch" for stable releases at the + // x.x.0 release. + final forkpoints = getFlutterVersions(flutterForkpoint.hash); + logger.info('Forkpoints:'); + printVersions(forkpoints, indent: 2); + return ExitCode.success.code; } } diff --git a/packages/cutler/lib/commands/rebase_command.dart b/packages/cutler/lib/commands/rebase_command.dart index 9cc78a94..0eff919a 100644 --- a/packages/cutler/lib/commands/rebase_command.dart +++ b/packages/cutler/lib/commands/rebase_command.dart @@ -1,10 +1,11 @@ -import 'package:args/command_runner.dart'; +import 'package:cutler/commands/base.dart'; import 'package:cutler/config.dart'; import 'package:cutler/git_extensions.dart'; import 'package:cutler/model.dart'; import 'package:cutler/versions.dart'; import 'package:io/io.dart'; +/// Prints the latest commit for a given [branch] in a given [repo]. String printLatestForBranch(Repo repo, String branch) { final hash = repo.getLatestCommit(branch); final tags = repo.getTagsFor(hash); @@ -43,8 +44,11 @@ String rebaseRepo( return shorebird[repo].ref; } -class RebaseCommand extends Command { - RebaseCommand(); +/// Print the commands needed to rebase our repos onto the given Flutter +/// revision. +class RebaseCommand extends CutlerCommand { + /// Constructs a new [RebaseCommand] with a given [logger]. + RebaseCommand({required super.logger}); @override final name = 'rebase'; @override @@ -70,7 +74,7 @@ class RebaseCommand extends Command { .contentsAtPath(shorebirdStable, 'bin/internal/flutter.version'); final shorebird = getFlutterVersions(shorebirdFlutter); print('Shorebird stable:'); - printVersions(shorebird, 2); + printVersions(shorebird, indent: 2); final flutterForkpoint = Repo.flutter.getForkPoint(shorebird.flutter.hash); // This is slightly error-prone in that we're assuming that our engine and @@ -80,7 +84,7 @@ class RebaseCommand extends Command { // x.x.0 release. final forkpoints = getFlutterVersions(flutterForkpoint.hash); print('Forkpoints:'); - printVersions(forkpoints, 2); + printVersions(forkpoints, indent: 2); // Figure out the latest version of Flutter. final upstreamFlutter = @@ -88,7 +92,7 @@ class RebaseCommand extends Command { // Figure out what versions that Flutter depends on. final upstream = getFlutterVersions(upstreamFlutter); print('Upstream ${config.flutterChannel}:'); - printVersions(upstream, 2); + printVersions(upstream, indent: 2); Version doRebase(Repo repo) { final newHash = rebaseRepo( diff --git a/packages/cutler/lib/config.dart b/packages/cutler/lib/config.dart index 1c397cd6..5f96f346 100644 --- a/packages/cutler/lib/config.dart +++ b/packages/cutler/lib/config.dart @@ -2,6 +2,8 @@ import 'dart:io'; // https://github.com/dart-lang/sdk/issues/18466 // https://github.com/dart-lang/path/issues/117#issuecomment-1034313012 +/// Expands a path that may contain a user directory (`~`). If [env] is +/// provided, it will be used instead of [Platform.environment]. String expandUser(String path, {Map? env}) { // This is not "well written", but eventually I guess we should // write one and publish it if Dart doesn't add an equivalent method. @@ -20,7 +22,9 @@ String expandUser(String path, {Map? env}) { } // Config is basically just our typed ArgResults held as a global. +/// Global configuration object for Cutler. class Config { + /// Constructs a new [Config]. Config({ required this.checkoutsRoot, required this.verbose, @@ -28,13 +32,25 @@ class Config { required this.doUpdate, required this.flutterChannel, }); + + /// The root directory where checkouts can be found. final String checkoutsRoot; + + /// Whether to print verbose output. final bool verbose; + + /// Whether to perform a dry run. final bool dryRun; + + /// Whether to update checkouts. final bool doUpdate; + + /// The Flutter channel to use. final String flutterChannel; + /// The name of the release branch for Shorebird. final String shorebirdReleaseBranch = 'origin/stable'; } +/// The global configuration object for Cutler. late final Config config; diff --git a/packages/cutler/lib/git_extensions.dart b/packages/cutler/lib/git_extensions.dart index afddd461..dbffa9d2 100644 --- a/packages/cutler/lib/git_extensions.dart +++ b/packages/cutler/lib/git_extensions.dart @@ -35,6 +35,7 @@ String runCommand( return result.stdout.toString().trim(); } +/// Function to print the command that would be run, but not actually run it. void dryRunCommand( String executable, List arguments, { @@ -43,7 +44,9 @@ void dryRunCommand( print("$executable ${arguments.join(' ')}"); } +/// Extension methods for [Repo] to do actual `git` actions. extension RepoCommands on Repo { + /// Returns a [Version] for the given [hash]. Version versionFrom(String hash, {bool lookupTags = true}) { return Version( hash: hash, @@ -52,8 +55,10 @@ extension RepoCommands on Repo { ); } + /// Returns the working directory for this repo. String get workingDirectory => '${config.checkoutsRoot}/$path'; + /// Returns the latest commit for a given [branch] in this repo. String getLatestCommit(String branch) { return runCommand( 'git', @@ -62,6 +67,7 @@ extension RepoCommands on Repo { ); } + /// Returns the tags for a given [commit] in this repo. List getTagsFor(String commit) { final output = runCommand( 'git', @@ -74,6 +80,7 @@ extension RepoCommands on Repo { return output.split('\n'); } + /// Returns the fork point as a [Version] for this repo given a [forkBranch]. Version getForkPoint(String forkBranch) { final hash = runCommand( 'git', @@ -83,6 +90,8 @@ extension RepoCommands on Repo { return versionFrom(hash); } + /// Returns the contents of a file at a given [path] in this repo at a given + /// [commit]. String contentsAtPath(String commit, String path) { return runCommand( 'git', @@ -91,10 +100,12 @@ extension RepoCommands on Repo { ); } + /// Writes [contents] to a file at a given [path] in this repo. void writeFile(String path, String contents) { File(path).writeAsStringSync(contents); } + /// Commits to this repo with a given [message]. void commit(String message) { runCommand( 'git', @@ -103,6 +114,7 @@ extension RepoCommands on Repo { ); } + /// Returns a [Version] object representing the current HEAD of this repo. Version localHead() { return versionFrom( runCommand( @@ -114,7 +126,10 @@ extension RepoCommands on Repo { } } +/// Extension methods for [Version] to do actual `git` actions. extension VersionCommands on Version { + /// Returns the contents of a file at a given [path] in this repo at this + /// version. String contentsAtPath(String path) { return repo.contentsAtPath(hash, path); } diff --git a/packages/cutler/lib/model.dart b/packages/cutler/lib/model.dart index ef11af0b..d3e4e39e 100644 --- a/packages/cutler/lib/model.dart +++ b/packages/cutler/lib/model.dart @@ -2,24 +2,31 @@ import 'package:meta/meta.dart'; /// Configuration information for each of our repos. enum Repo { + /// Repo configuration representing the Shorebird repo. shorebird( name: 'shorebird', path: '_shorebird/shorebird', url: 'https://github.com/shorebirdtech/shorebird.git', upstreamBranch: 'origin/main', ), + + /// Repo configuration representing the Flutter repo. flutter( name: 'flutter', path: 'flutter', url: 'https://github.com/shorebirdtech/flutter.git', upstreamBranch: 'upstream/stable', ), + + /// Repo configuration representing the engine repo. engine( name: 'engine', path: 'engine/src/flutter', url: 'https://github.com/shorebirdtech/engine.git', upstreamBranch: 'upstream/master', ), + + /// Repo configuration representing the buildroot repo. buildroot( name: 'buildroot', path: 'engine/src', @@ -34,35 +41,57 @@ enum Repo { required this.upstreamBranch, }); + /// Returns the name (e.g. 'engine') of the repo. final String name; + + /// Returns the path (e.g. 'engine/src/flutter') of the repo. final String path; + + /// Returns the URL the repo is cloned from. final String url; + + /// Returns the name of the upstream branch. final String upstreamBranch; } /// Paths to version files in each repo. enum Paths { + /// Path to the engine DEPS file in the engine repo. engineDEPS('DEPS'), + + /// Path to the flutter engine version file in the flutter repo. flutterEngineVersion('bin/internal/engine.version'), + + /// Path to the flutter version file in the shorebird repo. shorebirdFlutterVersion('bin/internal/flutter.version'); const Paths(this.path); + + /// Returns the path (e.g. 'DEPS') of the file. final String path; } /// An object to pair a [hash] with a [repo]. @immutable class Version { + /// Constructs a new [Version] object for a given [hash] and [repo] + /// with optionally provided [aliases] for the hash (typically tag names). const Version({ required this.hash, required this.repo, this.aliases = const [], }); + /// The hash of the version. final String hash; + + /// Aliaes for the hash (typically tag names). final List aliases; + + /// The repo the version is from. final Repo repo; + /// Returns the first alias for the hash, or the hash if there are no aliases. String get ref => aliases.isEmpty ? hash : aliases.first; @override @@ -85,21 +114,31 @@ class Version { /// An object to hold a set of versions that make up a Flutter release. class VersionSet { + /// Constructs a new [VersionSet] with a given [engine], [flutter], and + /// [buildroot] version. const VersionSet({ required this.engine, required this.flutter, required this.buildroot, }); + + /// The engine version. final Version engine; + + /// The flutter version. final Version flutter; + + /// The buildroot version. final Version buildroot; + /// Returns the version for a given [repo]. Version operator [](Repo repo) => { Repo.engine: engine, Repo.flutter: flutter, Repo.buildroot: buildroot, }[repo]!; + /// Copies the VersionSet replacing any provided values. VersionSet copyWith({Version? engine, Version? flutter, Version? buildroot}) { return VersionSet( engine: engine ?? this.engine, diff --git a/packages/cutler/lib/versions.dart b/packages/cutler/lib/versions.dart index 19025078..5028a586 100644 --- a/packages/cutler/lib/versions.dart +++ b/packages/cutler/lib/versions.dart @@ -1,12 +1,17 @@ import 'package:cutler/git_extensions.dart'; import 'package:cutler/model.dart'; -void printVersions(VersionSet versions, int indent) { +/// Print VersionSet [versions] to stdout at a given [indent] level. +void printVersions(VersionSet versions, {int indent = 0}) { print("${' ' * indent}flutter ${versions.flutter}"); print("${' ' * indent}engine ${versions.engine}"); print("${' ' * indent}buildroot ${versions.buildroot}"); } +/// Returns a [VersionSet] for Flutter for a given [flutterHash]. +/// e.g. `flutterHash` might be `origin/stable` or `v1.22.0-12.1.pre`. +/// and this would return the set of versions (engine and buildroot) that +/// Flutter depends on for that release. VersionSet getFlutterVersions(String flutterHash) { final engineHash = Repo.flutter .contentsAtPath(flutterHash, 'bin/internal/engine.version') @@ -21,6 +26,7 @@ VersionSet getFlutterVersions(String flutterHash) { ); } +/// Parses the given DEPS file contents and returns the buildroot version. String parseBuildRoot(String depsContents) { final lines = depsContents.split('\n'); // Example: diff --git a/packages/cutler/pubspec.lock b/packages/cutler/pubspec.lock index 41c37629..4622ae52 100644 --- a/packages/cutler/pubspec.lock +++ b/packages/cutler/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + mason_logger: + dependency: "direct main" + description: + name: mason_logger + sha256: "389dda35ee44c8664490749b204de1ee2dd91fbe9a725c2798f3232010dc53de" + url: "https://pub.dev" + source: hosted + version: "0.2.6" matcher: dependency: transitive description: diff --git a/packages/cutler/pubspec.yaml b/packages/cutler/pubspec.yaml index 9532e5de..90da13b5 100644 --- a/packages/cutler/pubspec.yaml +++ b/packages/cutler/pubspec.yaml @@ -13,5 +13,6 @@ dev_dependencies: dependencies: args: ^2.4.0 io: ^1.0.4 + mason_logger: ^0.2.6 meta: ^1.9.1 path: ^1.8.3