Skip to content

Commit

Permalink
test: re-write cutler to be more testable
Browse files Browse the repository at this point in the history
I removed the 'rebase' command for now since it was dead.
'rebase' should be re-written later to just print the commands
needed since having it actually drive the rebase was a dream.
I separated "Repo" from "Checkout" and removed the global config object.
I moved to using a global logger and package scoped to match
other packages.
I added a bunch of tests.  This still doesn't have 100% coverage
but it should
be 100% testable now.
  • Loading branch information
eseidel committed Aug 25, 2023
1 parent ec6532a commit f6fa59c
Show file tree
Hide file tree
Showing 16 changed files with 498 additions and 393 deletions.
6 changes: 1 addition & 5 deletions packages/cutler/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -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
115 changes: 9 additions & 106 deletions packages/cutler/bin/cutler.dart
Original file line number Diff line number Diff line change
@@ -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,
},
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'dart:io';

import 'package:cutler/config.dart';
import 'package:cutler/logger.dart';
import 'package:cutler/model.dart';
import 'package:path/path.dart' as p;

Expand All @@ -17,35 +17,76 @@ String runCommand(
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 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.stderr}');
throw Exception(
'Failed to run $executable $arguments: ${result.stdout} ${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(' ')}");
/// 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.
extension RepoCommands on Repo {
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(
Expand All @@ -64,7 +105,7 @@ extension RepoCommands on Repo {
);
return Version(
hash: hash,
repo: this,
repo: repo,
aliases: lookupTags ? getTagsFor(hash) : [],
);
}
Expand All @@ -80,7 +121,7 @@ extension RepoCommands on Repo {
}

/// Returns the working directory for this repo.
String get workingDirectory => '${config.checkoutsRoot}/$path';
String get workingDirectory => '$_checkoutsRoot/${repo.path}';

/// Returns the latest commit for a given [branch] in this repo.
String getLatestCommit(String branch) {
Expand Down Expand Up @@ -127,7 +168,7 @@ 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);
File(p.join(workingDirectory, path)).writeAsStringSync(contents);
}

/// Commits to this repo with a given [message].
Expand All @@ -152,10 +193,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);
}
}
// 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);
// }
// }
47 changes: 32 additions & 15 deletions packages/cutler/lib/commands/base.dart
Original file line number Diff line number Diff line change
@@ -1,28 +1,45 @@
import 'package:args/command_runner.dart';
import 'package:cutler/checkout.dart';
import 'package:cutler/config.dart';
import 'package:cutler/git_extensions.dart';
import 'package:cutler/model.dart';
import 'package:mason_logger/mason_logger.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) {
final progress = logger.progress('Updating checkouts...');
for (final repo in Repo.values) {
progress.update('Updating ${repo.name}');
repo.fetchAll();
}
progress.complete('Checkouts updated!');
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 change: 0 additions & 1 deletion packages/cutler/lib/commands/commands.dart
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export 'rebase_command.dart';
export 'versions_command.dart';
Loading

0 comments on commit f6fa59c

Please sign in to comment.