test: rewrite cutler to be testable (#1161)
This commit is contained in:
@@ -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 +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
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
// }
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user