diff --git a/packages/cutler/FORKING_FLUTTER.md b/packages/cutler/FORKING_FLUTTER.md index a44447c9..8c632f20 100644 --- a/packages/cutler/FORKING_FLUTTER.md +++ b/packages/cutler/FORKING_FLUTTER.md @@ -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: diff --git a/packages/cutler/analysis_options.yaml b/packages/cutler/analysis_options.yaml index 6fcb3d3b..d9f67a3d 100644 --- a/packages/cutler/analysis_options.yaml +++ b/packages/cutler/analysis_options.yaml @@ -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 \ No newline at end of file diff --git a/packages/cutler/bin/cutler.dart b/packages/cutler/bin/cutler.dart index 96a8b28a..f3d6afe4 100644 --- a/packages/cutler/bin/cutler.dart +++ b/packages/cutler/bin/cutler.dart @@ -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 { - 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 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 = [ - Directory.current.path, - p.dirname(shorebirdDir), - // Internal checkouts use a _shorebird wrapper directory. - p.dirname(p.dirname(shorebirdDir)), - ]; - for (final directory in fallbackDirectories) { - if (missingDirectories(directory).isEmpty) { - print('Using $directory as checkouts root.'); - return directory; - } - } - _logger.err('Failed to find a valid checkouts root, tried:\n' - '${fallbackDirectories.join('\n')}'); - return ''; // Returning an invalid directory will cause validation to fail. - } - - @override - ArgResults parse(Iterable args) { - final results = super.parse(args); - - final rootDir = results['root'] as String? ?? fallbackRootDir(); - - final missingDirs = missingDirectories(rootDir); - if (missingDirs.isNotEmpty) { - _logger - ..err('Could not find a valid checkouts root.') - ..err('--root must be a directory containing the ' - 'following:\n${Repo.values.map((r) => r.path).join('\n')}') - ..err('Missing directories:\n${missingDirs.join('\n')}'); - exit(1); - } - - config = Config( - checkoutsRoot: expandUser(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 args) { - print(Platform.script.path); - Cutler().run(args); + runScoped( + () => Cutler().run(args), + values: { + loggerRef, + }, + ); } diff --git a/packages/cutler/lib/checkout.dart b/packages/cutler/lib/checkout.dart new file mode 100644 index 00000000..45016fd9 --- /dev/null +++ b/packages/cutler/lib/checkout.dart @@ -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 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 = {}; + + /// Returns an iterable of all checkouts. + Iterable 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 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); +// } +// } diff --git a/packages/cutler/lib/commands/base.dart b/packages/cutler/lib/commands/base.dart index 8ede23e3..c89b6c6d 100644 --- a/packages/cutler/lib/commands/base.dart +++ b/packages/cutler/lib/commands/base.dart @@ -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 { /// 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!'); + } } diff --git a/packages/cutler/lib/commands/commands.dart b/packages/cutler/lib/commands/commands.dart index d381ed63..347422bb 100644 --- a/packages/cutler/lib/commands/commands.dart +++ b/packages/cutler/lib/commands/commands.dart @@ -1,2 +1 @@ -export 'rebase_command.dart'; export 'versions_command.dart'; diff --git a/packages/cutler/lib/commands/rebase_command.dart b/packages/cutler/lib/commands/rebase_command.dart deleted file mode 100644 index 6dcfcef0..00000000 --- a/packages/cutler/lib/commands/rebase_command.dart +++ /dev/null @@ -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 ", 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; - } -} diff --git a/packages/cutler/lib/commands/versions_command.dart b/packages/cutler/lib/commands/versions_command.dart index 62166ef2..de730c16 100644 --- a/packages/cutler/lib/commands/versions_command.dart +++ b/packages/cutler/lib/commands/versions_command.dart @@ -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; } diff --git a/packages/cutler/lib/config.dart b/packages/cutler/lib/config.dart index 5f96f346..12f53797 100644 --- a/packages/cutler/lib/config.dart +++ b/packages/cutler/lib/config.dart @@ -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? 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; diff --git a/packages/cutler/lib/cutler.dart b/packages/cutler/lib/cutler.dart new file mode 100644 index 00000000..f270d776 --- /dev/null +++ b/packages/cutler/lib/cutler.dart @@ -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 { + /// 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 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 = [ + 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 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; + } +} diff --git a/packages/cutler/lib/git_extensions.dart b/packages/cutler/lib/git_extensions.dart deleted file mode 100644 index fbe48eee..00000000 --- a/packages/cutler/lib/git_extensions.dart +++ /dev/null @@ -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 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 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 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); - } -} diff --git a/packages/cutler/lib/logger.dart b/packages/cutler/lib/logger.dart new file mode 100644 index 00000000..f09f7e54 --- /dev/null +++ b/packages/cutler/lib/logger.dart @@ -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); diff --git a/packages/cutler/lib/versions.dart b/packages/cutler/lib/versions.dart index 79ba8968..8499d681 100644 --- a/packages/cutler/lib/versions.dart +++ b/packages/cutler/lib/versions.dart @@ -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); diff --git a/packages/cutler/pubspec.lock b/packages/cutler/pubspec.lock index 70f4c2db..3f7e463d 100644 --- a/packages/cutler/pubspec.lock +++ b/packages/cutler/pubspec.lock @@ -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: diff --git a/packages/cutler/pubspec.yaml b/packages/cutler/pubspec.yaml index 90da13b5..2919c1ce 100644 --- a/packages/cutler/pubspec.yaml +++ b/packages/cutler/pubspec.yaml @@ -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 diff --git a/packages/cutler/test/checkout_test.dart b/packages/cutler/test/checkout_test.dart new file mode 100644 index 00000000..c8853dd2 --- /dev/null +++ b/packages/cutler/test/checkout_test.dart @@ -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()), + ); + }); + + 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), + }, + ); + }); +} diff --git a/packages/cutler/test/cutler_test.dart b/packages/cutler/test/cutler_test.dart index 9b232a44..1f2e59a1 100644 --- a/packages/cutler/test/cutler_test.dart +++ b/packages/cutler/test/cutler_test.dart @@ -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())); + }); + + test('findPackageRoot', () { + // Should throw an exception with the string 'test' in it. + expect( + findPackageRoot, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('test'), + ), + ), + ); }); } diff --git a/packages/cutler/test/model_test.dart b/packages/cutler/test/model_test.dart new file mode 100644 index 00000000..793812e7 --- /dev/null +++ b/packages/cutler/test/model_test.dart @@ -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'))); + }); +}