diff --git a/dev/tools/bin/engine_hash.dart b/dev/tools/bin/engine_hash.dart new file mode 100644 index 00000000000..5e8692ccead --- /dev/null +++ b/dev/tools/bin/engine_hash.dart @@ -0,0 +1,152 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:crypto/crypto.dart'; + +enum GitRevisionStrategy { + mergeBase, + head, +} + +final RegExp _hashRegex = RegExp(r'^([a-fA-F0-9]+)'); + +final ArgParser parser = ArgParser() + ..addOption( + 'strategy', + abbr: 's', + allowed: ['head', 'mergeBase'], + defaultsTo: 'head', + allowedHelp: { + 'head': 'hash from git HEAD', + 'mergeBase': 'hash from the merge-base of HEAD and upstream/master', + }, + ) + ..addFlag('help', abbr: 'h', negatable: false); + +Never printHelp({String? error}) { + final Stdout out = error != null ? stderr : stdout; + if (error != null) { + out.writeln(error); + out.writeln(); + } + out.writeln(''' +Calculate the hash signature for the Flutter Engine +${parser.usage} +'''); + exit(error != null ? 1 : 0); +} + +Future main(List args) async { + final ArgResults arguments; + try { + arguments = parser.parse(args); + } catch (e) { + printHelp(error: '$e'); + } + + if (arguments.wasParsed('help')) { + printHelp(); + } + + final String result; + try { + result = await engineHash( + (List command) => Process.run( + command.first, + command.sublist(1), + stdoutEncoding: utf8, + ), + revisionStrategy: GitRevisionStrategy.values.byName( + arguments.option('strategy')!, + ), + ); + } catch (e) { + stderr.writeln('Error calculating engine hash: $e'); + return 1; + } + + stdout.writeln(result); + + return 0; +} + +/// Returns the hash signature for the engine source code. +Future engineHash( + Future Function(List command) runProcess, { + GitRevisionStrategy revisionStrategy = GitRevisionStrategy.mergeBase, +}) async { + // First figure out the hash we're working with + final String base; + switch (revisionStrategy) { + case GitRevisionStrategy.head: + base = 'HEAD'; + case GitRevisionStrategy.mergeBase: + final ProcessResult processResult = await runProcess( + [ + 'git', + 'merge-base', + 'upstream/main', + 'HEAD', + ], + ); + + if (processResult.exitCode != 0) { + throw ''' +Unable to find merge-base hash of the repository: +${processResult.stderr}'''; + } + + final Match? baseHash = + _hashRegex.matchAsPrefix(processResult.stdout as String); + if (baseHash?.groupCount != 1) { + throw ''' +Unable to parse merge-base hash of the repository +${processResult.stdout}'''; + } + base = baseHash![1]!; + } + + // List the tree (not the working tree) recursively for the merge-base. + // This is important for future filtering of files, but also do not include + // the developer's changes / in flight PRs. + // The presence `engine` and `DEPS` are signals that you live in a monorepo world. + final ProcessResult processResult = await runProcess( + ['git', 'ls-tree', '-r', base, 'engine', 'DEPS'], + ); + + if (processResult.exitCode != 0) { + throw ''' +Unable to list tree +${processResult.stderr}'''; + } + + // Ensure stable line endings so our hash calculation is stable + final String lsTree = processResult.stdout as String; + if (lsTree.trim().isEmpty) { + throw 'Not in a monorepo'; + } + + final Iterable treeLines = + LineSplitter.split(processResult.stdout as String); + + // We could call `git hash-object --stdin` which would just take the input, calculate the size, + // and then sha1sum it like: `blob $size\0$string'. However, that can have different line endings. + // Instead this is equivalent to: + // git ls-tree -r $(git merge-base upstream/main HEAD) | | sha1sum + final StreamController output = StreamController(); + final ByteConversionSink sink = sha1.startChunkedConversion(output); + for (final String line in treeLines) { + sink.add(utf8.encode(line)); + sink.add([0x0a]); + } + sink.close(); + final Digest digest = await output.stream.first; + + return '$digest'; +} diff --git a/dev/tools/test/engine_hash_test.dart b/dev/tools/test/engine_hash_test.dart new file mode 100644 index 00000000000..6578a1b3fdc --- /dev/null +++ b/dev/tools/test/engine_hash_test.dart @@ -0,0 +1,102 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; + +import 'package:collection/collection.dart'; +import 'package:test/test.dart'; + +import '../bin/engine_hash.dart' show GitRevisionStrategy, engineHash; + +void main() { + test('Produces an engine hash for merge-base', () async { + final Future Function(List) runProcess = + _fakeProcesses(processes: [ + ( + exe: 'git', + command: 'merge-base', + rest: ['upstream/main', 'HEAD'], + exitCode: 0, + stdout: 'abcdef1234', + stderr: null + ), + ( + exe: 'git', + command: 'ls-tree', + rest: ['-r', 'abcdef1234', 'engine', 'DEPS'], + exitCode: 0, + stdout: 'one\r\ntwo\r\n', + stderr: null + ), + ]); + + final Future result = engineHash(runProcess); + + expect(result, completion('c708d7ef841f7e1748436b8ef5670d0b2de1a227')); + }); + + test('Produces an engine hash for HEAD', () async { + final Future Function(List) runProcess = + _fakeProcesses( + processes: [ + ( + exe: 'git', + command: 'ls-tree', + rest: ['-r', 'HEAD', 'engine', 'DEPS'], + exitCode: 0, + stdout: 'one\ntwo\n', + stderr: null + ), + ], + ); + + final Future result = + engineHash(runProcess, revisionStrategy: GitRevisionStrategy.head); + + expect(result, completion('c708d7ef841f7e1748436b8ef5670d0b2de1a227')); + }); + + test('Returns error in non-monorepo', () async { + final Future Function(List) runProcess = + _fakeProcesses(processes: [ + ( + exe: 'git', + command: 'ls-tree', + rest: ['-r', 'HEAD', 'engine', 'DEPS'], + exitCode: 0, + stdout: '', + stderr: null + ), + ]); + + final Future result = + engineHash(runProcess, revisionStrategy: GitRevisionStrategy.head); + + expect(result, throwsA('Not in a monorepo')); + }); +} + +typedef FakeProcess = ({ + String exe, + String command, + List rest, + dynamic stdout, + dynamic stderr, + int exitCode +}); + +Future Function(List) _fakeProcesses({ + required List processes, +}) => + (List cmd) async { + for (final FakeProcess process in processes) { + if (process.exe.endsWith(cmd[0]) && + process.command.endsWith(cmd[1]) && + process.rest.equals(cmd.sublist(2))) { + return io.ProcessResult( + 1, process.exitCode, process.stdout, process.stderr); + } + } + return io.ProcessResult(1, -42, '', '404 command not found: $cmd'); + };