test: rewrite cutler to be testable (#1161)

This commit is contained in:
Eric Seidel
2023-08-28 08:32:29 -07:00
committed by GitHub
parent 5526748ecf
commit 899400a4f0
18 changed files with 684 additions and 527 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ Eventually we'll automate stable, beta and master updates in the cloud.
Example output from updating 3.7.10 to 3.10.0:
```
dart run cutler --no-update --root=$HOME/Documents/GitHub --flutter-channel=beta --dry-run
dart run cutler rebase --no-update --root=$HOME/Documents/GitHub --flutter-channel=beta --dry-run
Building package executable...
Built cutler:cutler.
Shorebird stable:
+1 -5
View File
@@ -1,5 +1 @@
include: package:very_good_analysis/analysis_options.5.0.0.yaml
linter:
rules:
# avoid_print can be removed now that we have a logger.
avoid_print: false
include: package:very_good_analysis/analysis_options.5.0.0.yaml
+9 -106
View File
@@ -1,109 +1,12 @@
import 'dart:io';
import 'package:args/args.dart';
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({Logger? logger})
: _logger = logger ?? Logger(),
super('cutler', 'A tool for maintaining forks of Flutter.') {
addCommand(RebaseCommand(logger: _logger));
addCommand(VersionsCommand(logger: _logger));
argParser
..addFlag('verbose', abbr: 'v')
..addOption(
'root',
help: 'Directory in which to find checkouts.',
)
..addOption(
'flutter-channel',
defaultsTo: 'stable',
help: 'Upstream channel to propose rebasing onto.',
)
..addFlag('dry-run', defaultsTo: true, help: 'Do not actually run git.')
..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(),
);
}
// This behavior belongs in the Dart SDK somewhere.
String findPackageRoot() {
// e.g. `dart run bin/cutler.dart`
final scriptPath = Platform.script.path;
if (scriptPath.endsWith('.dart')) {
final cutlerBin = p.dirname(Platform.script.path);
return p.dirname(cutlerBin);
}
// `dart run` pre-compiles into a snapshot and then runs, e.g.
// .../packages/cutler/.dart_tool/pub/bin/cutler/cutler.dart-3.0.2.snapshot
if (scriptPath.endsWith('.snapshot') && scriptPath.contains('.dart_tool')) {
return scriptPath.split('.dart_tool').first;
}
throw UnimplementedError('Could not find package root.');
}
String fallbackRootDir() {
final cutlerRoot = findPackageRoot();
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(rootDir),
verbose: results['verbose'] as bool,
dryRun: results['dry-run'] as bool,
doUpdate: results['update'] as bool,
flutterChannel: results['flutter-channel'] as String,
);
return results;
}
}
import 'package:cutler/cutler.dart';
import 'package:cutler/logger.dart';
import 'package:scoped/scoped.dart';
void main(List<String> args) {
print(Platform.script.path);
Cutler().run(args);
runScoped(
() => Cutler().run(args),
values: {
loggerRef,
},
);
}
+202
View File
@@ -0,0 +1,202 @@
import 'dart:io';
import 'package:cutler/logger.dart';
import 'package:cutler/model.dart';
import 'package:path/path.dart' as p;
/// This file provides the git extensions to our model objects for Cutler.
/// That lets the models be pure data objects, and keeps the command-running
/// code separate. Unsure if this is a good design or not.
String runCommand(
String executable,
List<String> arguments, {
String? workingDirectory,
}) {
if (workingDirectory != null && !Directory(workingDirectory).existsSync()) {
throw Exception('Directory $workingDirectory does not exist.');
}
final workingDirectoryString = workingDirectory == null ||
p.equals(workingDirectory, Directory.current.path)
? ''
: ' (in $workingDirectory)';
logger.detail("$executable ${arguments.join(' ')}$workingDirectoryString");
final result = Process.runSync(
executable,
arguments,
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) {
throw Exception(
'Failed to run $executable $arguments: ${result.stdout} ${result.stderr}',
);
}
return result.stdout.toString().trim();
}
/// Represents all the checkouts cutler knows about.
class Checkouts {
/// Constructs a new [Checkouts] object with a given [root] directory.
Checkouts(this.root) {
for (final repo in Repo.values) {
_checkouts[repo] = Checkout(repo, root);
}
}
/// The root directory for all checkouts.
final String root;
/// The checkouts.
final _checkouts = <Repo, Checkout>{};
/// Returns an iterable of all checkouts.
Iterable<Checkout> get values => _checkouts.values;
/// Returns a [Checkout] for a given [repo].
Checkout operator [](Repo repo) => _checkouts[repo]!;
/// Returns a [Checkout] for Flutter.
Checkout get flutter => _checkouts[Repo.flutter]!;
/// Returns a [Checkout] for Engine.
Checkout get engine => _checkouts[Repo.engine]!;
/// Returns a [Checkout] for Dart.
Checkout get dart => _checkouts[Repo.dart]!;
/// Returns a [Checkout] for Buildroot.
Checkout get buildroot => _checkouts[Repo.buildroot]!;
/// Returns a [Checkout] for Shorebird.
Checkout get shorebird => _checkouts[Repo.shorebird]!;
}
/// Extension methods for [Repo] to do actual `git` actions.
class Checkout {
/// Constructs a new [Checkout] for a given [repo].
Checkout(this.repo, String checkoutsRoot) : _checkoutsRoot = checkoutsRoot;
/// The repo this checkout is for.
final Repo repo;
/// The root directory for all checkouts.
final String _checkoutsRoot;
/// The name of this repo.
String get name => repo.name;
/// Updates this repo.
void fetchAll() {
runCommand(
'git',
['fetch', '--all', '--tags'],
workingDirectory: workingDirectory,
);
}
/// Returns a [Version] for the given [commitish].
Version versionFrom(String commitish, {bool lookupTags = true}) {
final hash = runCommand(
'git',
['rev-parse', commitish],
workingDirectory: workingDirectory,
);
return Version(
hash: hash,
repo: repo,
aliases: lookupTags ? getTagsFor(hash) : [],
);
}
/// Returns a count of commits between two commits in this repo.
int countCommits({required String from, required String to}) {
final output = runCommand(
'git',
['rev-list', '--count', '$from..$to'],
workingDirectory: workingDirectory,
);
return int.parse(output);
}
/// Returns the working directory for this repo.
String get workingDirectory => '$_checkoutsRoot/${repo.path}';
/// Returns the latest commit for a given [branch] in this repo.
String getLatestCommit(String branch) {
return runCommand(
'git',
['log', '-1', '--pretty=%H', branch],
workingDirectory: workingDirectory,
);
}
/// Returns the tags for a given [commit] in this repo.
List<String> getTagsFor(String commit) {
final output = runCommand(
'git',
['tag', '--points-at', commit],
workingDirectory: workingDirectory,
);
if (output.isEmpty) {
return [];
}
return output.split('\n');
}
/// Returns the fork point as a [Version] for this repo given a [forkBranch].
Version getForkPoint(String forkBranch) {
final describeString = runCommand(
'git',
['describe', '--tags', forkBranch],
workingDirectory: workingDirectory,
);
final tag = describeString.split('-').first;
return versionFrom(tag);
}
/// 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',
['show', '$commit:$path'],
workingDirectory: workingDirectory,
);
}
/// Writes [contents] to a file at a given [path] in this repo.
void writeFile(String path, String contents) {
File(p.join(workingDirectory, path)).writeAsStringSync(contents);
}
/// Commits to this repo with a given [message].
void commit(String message) {
runCommand(
'git',
['commit', '-a', '-m', message],
workingDirectory: workingDirectory,
);
}
/// Returns a [Version] object representing the current HEAD of this repo.
Version localHead() {
return versionFrom(
runCommand(
'git',
['rev-parse', 'HEAD'],
workingDirectory: workingDirectory,
),
);
}
}
/// 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 Checkout(repo).contentsAtPath(hash, path);
// }
// }
+38 -6
View File
@@ -1,13 +1,45 @@
import 'package:args/command_runner.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:cutler/checkout.dart';
import 'package:cutler/config.dart';
import 'package:cutler/cutler.dart';
import 'package:cutler/logger.dart';
/// Base class for Cutler subcommands.
abstract class CutlerCommand extends Command<int> {
/// Constructs a new [CutlerCommand].
CutlerCommand({
required this.logger,
});
CutlerCommand();
/// The logger to use for this command.
final Logger logger;
/// The global config, set during argument parsing.
Config get config => (runner! as Cutler).config;
/// The checkout objects
late final Checkouts checkouts;
/// The Flutter checkout.
Checkout get flutter => checkouts.flutter;
/// The Engine checkout.
Checkout get engine => checkouts.engine;
/// The Shorebird checkout.
Checkout get shorebird => checkouts.shorebird;
/// The Buildroot checkout.
Checkout get buildroot => checkouts.buildroot;
/// The Dart checkout.
Checkout get dart => checkouts.dart;
/// Update the repos if needed.
void updateReposIfNeeded(Config config) {
if (!config.doUpdate) {
return;
}
final progress = logger.progress('Updating checkouts...');
for (final checkout in checkouts.values) {
progress.update('Updating ${checkout.name}');
checkout.fetchAll();
}
progress.complete('Checkouts updated!');
}
}
@@ -1,2 +1 @@
export 'rebase_command.dart';
export 'versions_command.dart';
@@ -1,206 +0,0 @@
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);
final tagsString = tags.isEmpty ? '' : " (${tags.join(', ')})";
print('${repo.name.padRight(10)} ${branch.padRight(25)} $hash$tagsString');
return hash;
}
/// Generate rebase commands for the repo given the version sets.
String rebaseRepo(
Repo repo, {
required VersionSet forkpoints,
required VersionSet upstream,
required VersionSet shorebird,
bool dryRun = true,
}) {
final run = dryRun ? dryRunCommand : runCommand;
if (upstream[repo] != forkpoints[repo]) {
print('Rebasing ${repo.name}...');
run(
'git',
[
'rebase',
'--onto',
upstream[repo].ref,
forkpoints[repo].ref,
shorebird[repo].ref,
],
workingDirectory: repo.workingDirectory,
);
return dryRun ? 'new-${repo.name}-hash' : repo.getLatestCommit('HEAD');
} else {
print('Skipping ${repo.name} (unchanged: ${upstream[repo].ref})');
}
return shorebird[repo].ref;
}
/// 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
final description = 'Rebase our repos onto the latest Flutter.';
@override
int run() {
if (config.doUpdate) {
print('Updating checkouts (use --no-update to skip)');
for (final repo in Repo.values) {
print('Updating ${repo.name}...');
repo.fetchAll();
}
}
final shorebirdStable =
Repo.shorebird.getLatestCommit(config.shorebirdReleaseBranch);
final shorebirdFlutter = Repo.shorebird
.contentsAtPath(shorebirdStable, 'bin/internal/flutter.version');
final shorebird = getFlutterVersions(shorebirdFlutter);
print('Shorebird stable:');
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);
print('Forkpoints:');
printVersions(forkpoints, indent: 2);
// Figure out the latest version of Flutter.
final upstreamFlutter =
Repo.flutter.getLatestCommit('upstream/${config.flutterChannel}');
// Figure out what versions that Flutter depends on.
final upstream = getFlutterVersions(upstreamFlutter);
print('Upstream ${config.flutterChannel}:');
printVersions(upstream, indent: 2);
Version doRebase(Repo repo) {
final newHash = rebaseRepo(
repo,
forkpoints: forkpoints,
upstream: upstream,
shorebird: shorebird,
dryRun: config.dryRun,
);
return repo.versionFrom(newHash, lookupTags: !config.dryRun);
}
// Rebase our repos.
// These are done in a very specific order.
var newHead = VersionSet(
buildroot: doRebase(Repo.buildroot),
dart: doRebase(Repo.dart),
engine: doRebase(Repo.engine),
flutter: doRebase(Repo.flutter),
);
// Make sure engine points to this buildroot.
// If not, update it and commit.
if (shorebird[Repo.buildroot] != newHead.buildroot) {
print('Updating engine DEPS...');
final depsContents =
shorebird[Repo.engine].contentsAtPath(Paths.engineDEPS.path);
final newDepsContents = depsContents.replaceAll(
shorebird[Repo.buildroot].hash,
newHead.buildroot.hash,
);
if (newDepsContents != depsContents) {
if (config.dryRun) {
print('Would have changed DEPS lines:');
final changes = newDepsContents.split('\n').where((line) {
return line.contains(newHead.buildroot.hash);
});
print(changes);
} else {
Repo.engine.writeFile(Paths.engineDEPS.path, newDepsContents);
Repo.engine.commit('Update DEPS.');
}
} else {
print('ERROR: engine DEPS is already up to date?');
return ExitCode.software.code;
}
newHead = newHead.copyWith(engine: Repo.engine.localHead());
}
// Update our forked flutter's engine version.
if (shorebird[Repo.engine] != newHead.engine) {
print('Updating flutter engine version...');
final existingEngineVersion = shorebird[Repo.flutter]
.contentsAtPath(Paths.flutterEngineVersion.path)
.trim();
if (newHead.engine.hash != existingEngineVersion) {
if (config.dryRun) {
print(
' Change engine.version: ${newHead.engine.hash} from '
'$existingEngineVersion',
);
} else {
Repo.flutter
.writeFile(Paths.flutterEngineVersion.path, newHead.engine.hash);
Repo.flutter.commit('Update engine.version');
}
} else {
print('ERROR: flutter engine.version is already up to date?');
return ExitCode.software.code;
}
newHead = newHead.copyWith(flutter: Repo.flutter.localHead());
}
// Update Shorebird's version of Flutter.
if (shorebird[Repo.flutter] != newHead.flutter) {
print('Updating shorebird flutter version...');
if (config.dryRun) {
print(
' Change flutter.version: ${newHead.flutter.hash} from '
'${shorebird[Repo.flutter].hash}',
);
} else {
Repo.shorebird.writeFile(
Paths.shorebirdFlutterVersion.path,
newHead.flutter.hash,
);
Repo.shorebird.commit('Update flutter.version');
}
}
// To push a new engine:
// git branch stable_codepush HEAD --force
// git push origin stable_codepush --force
// Engine rev: d470ae25d21f583abe128f7b838476afd5e45bde
// To push new flutter:
// % git rebase --onto 3.7.12 3.7.10 \
// 45fc514f1a9c347a3af76b02baf980a4d88b7879
// Auto-merging bin/internal/engine.version
// CONFLICT (content): Merge conflict in bin/internal/engine.version
// error: could not apply c2185f5f6c... chore: Update engine version to
// shorebird-3.7.10
// hint: Resolve all conflicts manually, mark them as resolved with
// hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
// hint: You can instead skip this commit: run "git rebase --skip".
// hint: To abort and get back to the state before "git rebase", run "git
// rebase --abort".
// Could not apply c2185f5f6c... chore: Update engine version to
// shorebird-3.7.10
// Flutter revision: 58dff390738f9c512ab7e0638af9573515b0409c
// To push new shorebird:
return ExitCode.success.code;
}
}
@@ -1,6 +1,6 @@
import 'package:cutler/checkout.dart';
import 'package:cutler/commands/base.dart';
import 'package:cutler/config.dart';
import 'package:cutler/git_extensions.dart';
import 'package:cutler/logger.dart';
import 'package:cutler/model.dart';
import 'package:cutler/versions.dart';
import 'package:io/io.dart';
@@ -8,7 +8,7 @@ import 'package:io/io.dart';
/// Print the versions a given Shorebird or Flutter hash depends on.
class VersionsCommand extends CutlerCommand {
/// Constructs a new [VersionsCommand] with a given [logger].
VersionsCommand({required super.logger}) {
VersionsCommand() {
argParser.addOption(
'repo',
abbr: 'r',
@@ -25,47 +25,44 @@ class VersionsCommand extends CutlerCommand {
@override
int run() {
checkouts = Checkouts(config.checkoutsRoot);
final repoName = argResults!['repo'] as String;
final repo = Repo.values.firstWhere((r) => r.name == repoName);
final isShorebird = repo.name == 'shorebird';
late final String hash;
if (argResults!.rest.isEmpty) {
if (isShorebird) {
print('No version hash provided, using Shorebird `origin/stable`.');
hash = 'origin/stable';
} else {
print('No version hash provided, using Flutter `upstream/stable`.');
hash = 'upstream/stable';
}
hash = isShorebird ? 'origin/stable' : 'upstream/stable';
} else {
hash = argResults!.rest.first;
}
if (config.doUpdate) {
print('Updating checkouts (use --no-update to skip)');
for (final repo in Repo.values) {
print('Updating ${repo.name}...');
repo.fetchAll();
}
updateReposIfNeeded(config);
if (!isShorebird) {
final flutterVersions = getFlutterVersions(checkouts, hash);
logger.info('Flutter $hash:');
printVersions(checkouts, flutterVersions, indent: 2);
return ExitCode.success.code;
}
late final String flutterHash;
if (isShorebird) {
final shorebirdFlutter =
Repo.shorebird.contentsAtPath(hash, 'bin/internal/flutter.version');
final shorebird = getFlutterVersions(shorebirdFlutter);
logger.info('Shorebird $hash:');
printVersions(shorebird, indent: 2);
final flutterForkpoint =
Repo.flutter.getForkPoint(shorebird.flutter.hash);
flutterHash = flutterForkpoint.hash;
} else {
flutterHash = hash;
}
final shorebirdFlutter =
shorebird.contentsAtPath(hash, 'bin/internal/flutter.version');
final shorebirdVersions = getFlutterVersions(checkouts, shorebirdFlutter);
final flutterForkpoint =
flutter.getForkPoint(shorebirdVersions.flutter.hash);
final flutterHash = flutterForkpoint.hash;
final flutterVersions = getFlutterVersions(checkouts, flutterHash);
final flutterVersions = getFlutterVersions(flutterHash);
logger.info('Flutter $flutterHash:');
printVersions(flutterVersions, indent: 2);
logger.info('Shorebird @ $hash');
printVersions(
checkouts,
shorebirdVersions,
indent: 2,
upstream: flutterVersions,
);
logger.info('\nUpstream');
printVersions(checkouts, flutterVersions, indent: 2);
return ExitCode.success.code;
}
+24 -7
View File
@@ -1,5 +1,7 @@
import 'dart:io';
import 'package:path/path.dart' as p;
// 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
@@ -21,13 +23,34 @@ String expandUser(String path, {Map<String, String>? env}) {
return path;
}
// This behavior belongs in the Dart SDK somewhere.
/// Find the package root for the current running script.
String findPackageRoot() {
// e.g. `dart run bin/cutler.dart`
final scriptPath = Platform.script.path;
if (scriptPath.endsWith('.dart')) {
final cutlerBin = p.dirname(Platform.script.path);
return p.dirname(cutlerBin);
}
// `dart run` pre-compiles into a snapshot and then runs, e.g.
// .../packages/cutler/.dart_tool/pub/bin/cutler/cutler.dart-3.0.2.snapshot
if (scriptPath.endsWith('.snapshot') && scriptPath.contains('.dart_tool')) {
return scriptPath.split('.dart_tool').first;
}
// package test has scriptPath ending in .dill, e.g.
// /var/folders/fv/fqqmfjqd6zvdqrbrv78gt4m80000gn/T/dart_test.kernel.PhFW66/test.dart_4.dill
if (scriptPath.endsWith('.dill')) {
throw UnimplementedError('Running inside a test');
}
throw UnimplementedError('Could not find package root: $scriptPath');
}
// 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,
required this.dryRun,
required this.doUpdate,
required this.flutterChannel,
@@ -36,9 +59,6 @@ class Config {
/// 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;
@@ -51,6 +71,3 @@ class Config {
/// The name of the release branch for Shorebird.
final String shorebirdReleaseBranch = 'origin/stable';
}
/// The global configuration object for Cutler.
late final Config config;
+94
View File
@@ -0,0 +1,94 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:cutler/commands/commands.dart';
import 'package:cutler/config.dart';
import 'package:cutler/logger.dart';
import 'package:cutler/model.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
/// The main Cutler command runner.
class Cutler extends CommandRunner<int> {
/// Constructs a new [Cutler] command runner.
Cutler() : super('cutler', 'A tool for maintaining forks of Flutter.') {
addCommand(VersionsCommand());
argParser
..addFlag('verbose', abbr: 'v')
..addOption(
'root',
help: 'Directory in which to find checkouts.',
)
..addOption(
'flutter-channel',
defaultsTo: 'stable',
help: 'Upstream channel to propose rebasing onto.',
)
..addFlag('dry-run', defaultsTo: true, help: 'Do not actually run git.')
..addFlag('update', defaultsTo: true, help: 'Update checkouts.');
}
/// Global config set during arg parsing.
late Config config;
/// Lists directories missing from the proposed root directory.
Iterable<String> missingDirectories(String rootDir) {
return Repo.values.map((repo) => '$rootDir/${repo.path}').where(
(path) => !Directory(path).existsSync(),
);
}
/// Find a valid root directory for checkouts.
String fallbackRootDir() {
final cutlerRoot = findPackageRoot();
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) {
logger.info('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(rootDir),
dryRun: results['dry-run'] as bool,
doUpdate: results['update'] as bool,
flutterChannel: results['flutter-channel'] as String,
);
if (results['verbose'] as bool) {
logger.level = Level.verbose;
}
return results;
}
}
-145
View File
@@ -1,145 +0,0 @@
import 'dart:io';
import 'package:cutler/config.dart';
import 'package:cutler/model.dart';
import 'package:path/path.dart' as p;
/// This file provides the git extensions to our model objects for Cutler.
/// That lets the models be pure data objects, and keeps the command-running
/// code separate. Unsure if this is a good design or not.
String runCommand(
String executable,
List<String> arguments, {
String? workingDirectory,
}) {
if (workingDirectory != null && !Directory(workingDirectory).existsSync()) {
throw Exception('Directory $workingDirectory does not exist.');
}
if (config.verbose) {
final workingDirectoryString = workingDirectory == null ||
p.equals(workingDirectory, Directory.current.path)
? ''
: ' (in $workingDirectory)';
print("$executable ${arguments.join(' ')}$workingDirectoryString");
}
final result = Process.runSync(
executable,
arguments,
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) {
throw Exception('Failed to run $executable $arguments: ${result.stderr}');
}
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, {
String? workingDirectory,
}) {
print("$executable ${arguments.join(' ')}");
}
/// Extension methods for [Repo] to do actual `git` actions.
extension RepoCommands on Repo {
/// Updates this repo.
void fetchAll() {
runCommand(
'git',
['fetch', '--all'],
workingDirectory: workingDirectory,
);
}
/// Returns a [Version] for the given [hash].
Version versionFrom(String hash, {bool lookupTags = true}) {
return Version(
hash: hash,
repo: this,
aliases: lookupTags ? getTagsFor(hash) : [],
);
}
/// 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',
['log', '-1', '--pretty=%H', branch],
workingDirectory: workingDirectory,
);
}
/// Returns the tags for a given [commit] in this repo.
List<String> getTagsFor(String commit) {
final output = runCommand(
'git',
['tag', '--points-at', commit],
workingDirectory: workingDirectory,
);
if (output.isEmpty) {
return [];
}
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',
['merge-base', upstreamBranch, forkBranch],
workingDirectory: workingDirectory,
);
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',
['show', '$commit:$path'],
workingDirectory: workingDirectory,
);
}
/// 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',
['commit', '-a', '-m', message],
workingDirectory: workingDirectory,
);
}
/// Returns a [Version] object representing the current HEAD of this repo.
Version localHead() {
return versionFrom(
runCommand(
'git',
['rev-parse', 'HEAD'],
workingDirectory: workingDirectory,
),
);
}
}
/// 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);
}
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:scoped/scoped.dart';
/// A reference to a [Logger] instance.
final loggerRef = create(Logger.new);
/// The [Logger] instance available in the current zone.
Logger get logger => read(loggerRef);
+42 -15
View File
@@ -1,8 +1,15 @@
import 'package:cutler/git_extensions.dart';
import 'package:collection/collection.dart';
import 'package:cutler/checkout.dart';
import 'package:cutler/logger.dart';
import 'package:cutler/model.dart';
/// Print VersionSet [versions] to stdout at a given [indent] level.
void printVersions(VersionSet versions, {int indent = 0}) {
void printVersions(
Checkouts checkouts,
VersionSet versions, {
int indent = 0,
VersionSet? upstream,
}) {
final repos = [
Repo.flutter,
Repo.engine,
@@ -10,7 +17,20 @@ void printVersions(VersionSet versions, {int indent = 0}) {
Repo.buildroot,
];
for (final repo in repos) {
print("${' ' * indent}${repo.name.padRight(9)} ${versions[repo]}");
final checkout = checkouts[repo];
final string = "${' ' * indent}${repo.name.padRight(9)} ${versions[repo]}";
// Include number of commits ahead of upstream.
if (upstream != null) {
final upstreamVersion = upstream[repo];
final commitCount = checkout.countCommits(
from: upstreamVersion.ref,
to: versions[repo].ref,
);
final commitsString = commitCount != 0 ? ' ($commitCount ahead)' : '';
logger.info('$string$commitsString');
} else {
logger.info(string);
}
}
}
@@ -18,19 +38,21 @@ void printVersions(VersionSet versions, {int indent = 0}) {
/// 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')
.trim();
final depsContents =
Repo.engine.contentsAtPath(engineHash, Paths.engineDEPS.path);
VersionSet getFlutterVersions(Checkouts checkouts, String flutterHash) {
final flutter = checkouts.flutter;
final engine = checkouts.engine;
final buildroot = checkouts.buildroot;
final dart = checkouts.dart;
final engineHash =
flutter.contentsAtPath(flutterHash, 'bin/internal/engine.version').trim();
final depsContents = engine.contentsAtPath(engineHash, Paths.engineDEPS.path);
final buildrootHash = parseBuildrootRevision(depsContents);
final dartHash = parseDartRevision(depsContents);
return VersionSet(
engine: Repo.engine.versionFrom(engineHash),
flutter: Repo.flutter.versionFrom(flutterHash),
buildroot: Repo.buildroot.versionFrom(buildrootHash),
dart: Repo.dart.versionFrom(dartHash),
engine: engine.versionFrom(engineHash),
flutter: flutter.versionFrom(flutterHash),
buildroot: buildroot.versionFrom(buildrootHash),
dart: dart.versionFrom(dartHash),
);
}
@@ -52,8 +74,13 @@ String parseBuildrootRevision(String depsContents) {
String parseDartRevision(String depsContents) {
final lines = depsContents.split('\n');
// Example:
// 'dart_revision': 'ce926bc6dcf649bd31a396e4e3961196115727cd',
final dartLine =
// 'dart_sdk_revision': 'ce926bc6dcf649bd31a396e4e3961196115727cd',
// In our fork we use dart_sdk_revision, not dart_revision, since the former
// points to our fork of the Dart SDK and the latter points to some base
// revision for dart.googlesource.com/sdk.
// For upstream we use 'dart_revision'.
final dartLine = lines
.firstWhereOrNull((line) => line.contains("'dart_sdk_revision': ")) ??
lines.firstWhere((line) => line.contains("'dart_revision': "));
final regexp = RegExp('([0-9a-f]{40})');
final match = regexp.firstMatch(dartLine);
+18 -3
View File
@@ -42,7 +42,7 @@ packages:
source: hosted
version: "2.1.1"
collection:
dependency: transitive
dependency: "direct main"
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
@@ -169,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
mocktail:
dependency: "direct main"
description:
name: mocktail
sha256: "9503969a7c2c78c7292022c70c0289ed6241df7a9ba720010c0b215af29a5a58"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
node_preamble:
dependency: transitive
description:
@@ -209,6 +217,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
scoped:
dependency: "direct main"
description:
path: "../scoped"
relative: true
source: path
version: "0.1.0+1"
shelf:
dependency: transitive
description:
@@ -341,10 +356,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d"
sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583
url: "https://pub.dev"
source: hosted
version: "11.9.0"
version: "11.10.0"
watcher:
dependency: transitive
description:
+5
View File
@@ -2,6 +2,7 @@ name: cutler
description: A tool for managing Flutter forks.
version: 1.0.0
repository: https://github.com/shorebirdtech/shorebird
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -12,7 +13,11 @@ dev_dependencies:
dependencies:
args: ^2.4.0
collection: ^1.18.0
io: ^1.0.4
mason_logger: ^0.2.6
meta: ^1.9.1
mocktail: ^1.0.0
path: ^1.8.3
scoped:
path: ../scoped
+97
View File
@@ -0,0 +1,97 @@
import 'dart:io';
import 'package:cutler/checkout.dart';
import 'package:cutler/logger.dart';
import 'package:cutler/model.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:scoped/scoped.dart';
import 'package:test/test.dart';
class _MockLogger extends Mock implements Logger {}
void main() {
test('runCommand', () {
final dartPath = Platform.executable;
expect(
() => runCommand(dartPath, [], workingDirectory: 'DOES_NOT_EXIST'),
throwsA(isA<Exception>()),
);
});
test('runCommand with git', () {
final systemTemp = Directory.systemTemp;
final temp = systemTemp.createTempSync();
// Should this use something like:
// https://pub.dev/packages/process_run?
// or https://dcli.onepub.dev/dcli-api/calling-apps#which
const gitPath = 'git';
final result = runScoped(
() {
return runCommand(gitPath, ['init'], workingDirectory: temp.path);
},
values: {
loggerRef.overrideWith(_MockLogger.new),
},
);
expect(result, contains('Initialized empty Git repository'));
});
test('Checkouts', () {
final checkouts = Checkouts('ROOT');
expect(checkouts.dart.name, 'dart');
expect(checkouts.engine.name, 'engine');
expect(checkouts.flutter.name, 'flutter');
expect(checkouts.buildroot.name, 'buildroot');
expect(checkouts.shorebird.name, 'shorebird');
expect(checkouts.values.length, 5);
expect(checkouts.buildroot.workingDirectory, 'ROOT/engine/src');
expect(checkouts.engine.workingDirectory, 'ROOT/engine/src/flutter');
expect(checkouts.dart.workingDirectory, 'ROOT/engine/src/third_party/dart');
expect(checkouts.flutter.workingDirectory, 'ROOT/flutter');
expect(checkouts.shorebird.workingDirectory, 'ROOT/_shorebird/shorebird');
});
Directory setupCheckouts() {
final systemTemp = Directory.systemTemp;
final checkoutsRoot = systemTemp.createTempSync();
for (final repo in Repo.values) {
final dir = Directory('${checkoutsRoot.path}/${repo.path}')
..createSync(recursive: true);
runCommand('git', ['init'], workingDirectory: dir.path);
final checkout = Checkout(repo, checkoutsRoot.path)
..writeFile('NAME', repo.name);
runCommand('git', ['add', 'NAME'], workingDirectory: dir.path);
// Git requires user.email user.name to be set before committing.
runCommand(
'git',
['config', 'user.email', 'test@shorebird.dev'],
workingDirectory: dir.path,
);
runCommand(
'git',
['config', 'user.name', 'Cutler Checkout Test'],
workingDirectory: dir.path,
);
checkout.commit('Test commit');
}
return checkoutsRoot;
}
test('Checkouts real git commands', () {
runScoped(
() {
final root = setupCheckouts();
final checkouts = Checkouts(root.path);
expect(checkouts.dart.contentsAtPath('HEAD', 'NAME'), 'dart');
expect(checkouts.engine.contentsAtPath('HEAD', 'NAME'), 'engine');
expect(checkouts.flutter.contentsAtPath('HEAD', 'NAME'), 'flutter');
expect(checkouts.buildroot.contentsAtPath('HEAD', 'NAME'), 'buildroot');
expect(checkouts.shorebird.contentsAtPath('HEAD', 'NAME'), 'shorebird');
},
values: {
loggerRef.overrideWith(_MockLogger.new),
},
);
});
}
+17
View File
@@ -5,5 +5,22 @@ void main() {
test('expandUser', () {
final path = expandUser('~/foo/bar', env: {'HOME': '/home/user'});
expect(path, endsWith('/home/user/foo/bar'));
// HOME is not set.
expect(() => expandUser('~/foo/bar', env: {}), throwsA(isA<Exception>()));
});
test('findPackageRoot', () {
// Should throw an exception with the string 'test' in it.
expect(
findPackageRoot,
throwsA(
isA<UnimplementedError>().having(
(e) => e.message,
'message',
contains('test'),
),
),
);
});
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:cutler/model.dart';
import 'package:test/test.dart';
void main() {
test('Version', () {
const one = Version(
repo: Repo.flutter,
hash: 'abc123',
);
const two = Version(
repo: Repo.flutter,
hash: 'abc123',
aliases: ['foo', 'bar'],
);
const three = Version(
repo: Repo.flutter,
hash: 'def456',
);
expect(one, equals(two));
expect(one, isNot(equals(three)));
expect(one.hashCode, equals(two.hashCode));
expect(one.hashCode, isNot(equals(three.hashCode)));
expect(one.ref, equals('abc123'));
expect(two.ref, equals('foo'));
expect(three.ref, equals('def456'));
expect(one.toString(), equals('abc123'));
expect(two.toString(), equals('abc123 (foo, bar)'));
expect(three.toString(), equals('def456'));
});
test('VersionSet', () {
const one = VersionSet(
engine: Version(
repo: Repo.engine,
hash: 'e1',
),
flutter: Version(
repo: Repo.flutter,
hash: 'f1',
),
buildroot: Version(
repo: Repo.buildroot,
hash: 'b1',
),
dart: Version(
repo: Repo.dart,
hash: 'd1',
),
);
final two = one.copyWith(
engine: const Version(
repo: Repo.engine,
hash: 'e2',
),
flutter: const Version(
repo: Repo.flutter,
hash: 'f2',
),
buildroot: const Version(
repo: Repo.buildroot,
hash: 'b2',
),
dart: const Version(
repo: Repo.dart,
hash: 'd2',
),
);
expect(one, isNot(equals(two)));
expect(one.hashCode, isNot(equals(two.hashCode)));
expect(
one[Repo.engine],
equals(const Version(repo: Repo.engine, hash: 'e1')),
);
expect(
one[Repo.flutter],
equals(const Version(repo: Repo.flutter, hash: 'f1')),
);
expect(
one[Repo.buildroot],
equals(const Version(repo: Repo.buildroot, hash: 'b1')),
);
expect(one[Repo.dart], equals(const Version(repo: Repo.dart, hash: 'd1')));
expect(
two[Repo.engine],
equals(const Version(repo: Repo.engine, hash: 'e2')),
);
expect(
two[Repo.flutter],
equals(const Version(repo: Repo.flutter, hash: 'f2')),
);
expect(
two[Repo.buildroot],
equals(const Version(repo: Repo.buildroot, hash: 'b2')),
);
expect(two[Repo.dart], equals(const Version(repo: Repo.dart, hash: 'd2')));
});
}