feat: Give cutler some sane default behavior (#578)

This commit is contained in:
Eric Seidel
2023-06-01 17:14:31 -04:00
committed by GitHub
parent 3c07866cdc
commit b8da256b5e
11 changed files with 188 additions and 27 deletions
+1 -1
View File
@@ -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
+52 -13
View File
@@ -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<int> {
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<int> {
..addFlag('update', defaultsTo: true, help: 'Update checkouts.');
}
final Logger _logger;
Iterable<String> 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 = <String>[
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<String> 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;
}
}
+13
View File
@@ -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<int> {
/// Constructs a new [CutlerCommand].
CutlerCommand({
required this.logger,
});
/// The logger to use for this command.
final Logger logger;
}
@@ -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<int> {
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<int> {
@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;
}
}
@@ -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<int> {
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<int> {
.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<int> {
// 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<int> {
// 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(
+16
View File
@@ -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<String, String>? 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<String, String>? 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;
+15
View File
@@ -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<String> 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<String> 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);
}
+39
View File
@@ -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<String> 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,
+7 -1
View File
@@ -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:
+8
View File
@@ -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:
+1
View File
@@ -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