Add update packages roller (#100982)
diff --git a/dev/bots/analyze.dart b/dev/bots/analyze.dart
index d4c87a8..c14bfcb 100644
--- a/dev/bots/analyze.dart
+++ b/dev/bots/analyze.dart
@@ -1778,6 +1778,7 @@
'dev/bots/docs.sh',
'dev/conductor/bin/conductor',
+ 'dev/conductor/bin/roll-packages',
'dev/conductor/core/lib/src/proto/compile_proto.sh',
'dev/customer_testing/ci.sh',
diff --git a/dev/conductor/bin/conductor b/dev/conductor/bin/conductor
index 29cb05e..3a5735a 100755
--- a/dev/conductor/bin/conductor
+++ b/dev/conductor/bin/conductor
@@ -40,4 +40,4 @@
# Ensure pub get has been run in the repo before running the conductor
(cd "$REPO_DIR/dev/conductor/core"; "$DART_BIN" pub get 1>&2)
-"$DART_BIN" --enable-asserts "$REPO_DIR/dev/conductor/core/bin/cli.dart" "$@"
+exec "$DART_BIN" --enable-asserts "$REPO_DIR/dev/conductor/core/bin/cli.dart" "$@"
diff --git a/dev/conductor/bin/roll-packages b/dev/conductor/bin/roll-packages
new file mode 100755
index 0000000..231d544
--- /dev/null
+++ b/dev/conductor/bin/roll-packages
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# 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.
+
+set -euo pipefail
+
+# Needed because if it is set, cd may print the path it changed to.
+unset CDPATH
+
+# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one
+# link at a time, and then cds into the link destination and find out where it
+# ends up.
+#
+# The returned filesystem path must be a format usable by Dart's URI parser,
+# since the Dart command line tool treats its argument as a file URI, not a
+# filename. For instance, multiple consecutive slashes should be reduced to a
+# single slash, since double-slashes indicate a URI "authority", and these are
+# supposed to be filenames. There is an edge case where this will return
+# multiple slashes: when the input resolves to the root directory. However, if
+# that were the case, we wouldn't be running this shell, so we don't do anything
+# about it.
+#
+# The function is enclosed in a subshell to avoid changing the working directory
+# of the caller.
+function follow_links() (
+ cd -P "$(dirname -- "$1")"
+ file="$PWD/$(basename -- "$1")"
+ while [[ -h "$file" ]]; do
+ cd -P "$(dirname -- "$file")"
+ file="$(readlink -- "$file")"
+ cd -P "$(dirname -- "$file")"
+ file="$PWD/$(basename -- "$file")"
+ done
+ echo "$file"
+)
+
+PROG_NAME="$(follow_links "${BASH_SOURCE[0]}")"
+BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)"
+REPO_DIR="$BIN_DIR/../../.."
+DART_BIN="$REPO_DIR/bin/dart"
+
+# Ensure pub get has been run in the repo before running the conductor
+(cd "$REPO_DIR/dev/conductor/core"; "$DART_BIN" pub get 1>&2)
+
+exec "$DART_BIN" --enable-asserts "$REPO_DIR/dev/conductor/core/bin/roll_packages.dart" "$@"
diff --git a/dev/conductor/core/bin/packages_autoroller.dart b/dev/conductor/core/bin/packages_autoroller.dart
new file mode 100644
index 0000000..d69c846
--- /dev/null
+++ b/dev/conductor/core/bin/packages_autoroller.dart
@@ -0,0 +1,128 @@
+// 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:args/args.dart';
+import 'package:conductor_core/conductor_core.dart';
+import 'package:conductor_core/packages_autoroller.dart';
+import 'package:file/file.dart';
+import 'package:file/local.dart';
+import 'package:platform/platform.dart';
+import 'package:process/process.dart';
+
+const String kTokenOption = 'token';
+const String kGithubClient = 'github-client';
+const String kMirrorRemote = 'mirror-remote';
+const String kUpstreamRemote = 'upstream-remote';
+
+Future<void> main(List<String> args) async {
+ final ArgParser parser = ArgParser();
+ parser.addOption(
+ kTokenOption,
+ help: 'GitHub access token env variable name.',
+ defaultsTo: 'GITHUB_TOKEN',
+ );
+ parser.addOption(
+ kGithubClient,
+ help: 'Path to GitHub CLI client. If not provided, it is assumed `gh` is '
+ 'present on the PATH.',
+ );
+ parser.addOption(
+ kMirrorRemote,
+ help: 'The mirror git remote that the feature branch will be pushed to. '
+ 'Required',
+ mandatory: true,
+ );
+ parser.addOption(
+ kUpstreamRemote,
+ help: 'The upstream git remote that the feature branch will be merged to.',
+ hide: true,
+ defaultsTo: 'https://github.com/flutter/flutter.git',
+ );
+
+ final ArgResults results;
+ try {
+ results = parser.parse(args);
+ } on FormatException {
+ io.stdout.writeln('''
+Usage:
+
+${parser.usage}
+''');
+ rethrow;
+ }
+
+ final String mirrorUrl = results[kMirrorRemote]! as String;
+ final String upstreamUrl = results[kUpstreamRemote]! as String;
+ const Platform platform = LocalPlatform();
+ final String tokenName = results[kTokenOption]! as String;
+ final String? token = platform.environment[tokenName];
+ if (token == null || token.isEmpty) {
+ throw FormatException(
+ 'Tried to read a GitHub access token from env variable \$$tokenName but it was undefined or empty',
+ );
+ }
+
+ final FrameworkRepository framework = FrameworkRepository(
+ _localCheckouts,
+ mirrorRemote: Remote.mirror(mirrorUrl),
+ upstreamRemote: Remote.upstream(upstreamUrl),
+ );
+
+ await PackageAutoroller(
+ framework: framework,
+ githubClient: results[kGithubClient] as String? ?? 'gh',
+ orgName: _parseOrgName(mirrorUrl),
+ token: token,
+ processManager: const LocalProcessManager(),
+ ).roll();
+}
+
+String _parseOrgName(String remoteUrl) {
+ final RegExp pattern = RegExp(r'^https:\/\/github\.com\/(.*)\/');
+ final RegExpMatch? match = pattern.firstMatch(remoteUrl);
+ if (match == null) {
+ throw FormatException(
+ 'Malformed upstream URL "$remoteUrl", should start with "https://github.com/"',
+ );
+ }
+ return match.group(1)!;
+}
+
+Checkouts get _localCheckouts {
+ const FileSystem fileSystem = LocalFileSystem();
+ const ProcessManager processManager = LocalProcessManager();
+ const Platform platform = LocalPlatform();
+ final Stdio stdio = VerboseStdio(
+ stdout: io.stdout,
+ stderr: io.stderr,
+ stdin: io.stdin,
+ );
+ return Checkouts(
+ fileSystem: fileSystem,
+ parentDirectory: _localFlutterRoot.parent,
+ platform: platform,
+ processManager: processManager,
+ stdio: stdio,
+ );
+}
+
+Directory get _localFlutterRoot {
+ String filePath;
+ const FileSystem fileSystem = LocalFileSystem();
+ const Platform platform = LocalPlatform();
+
+ filePath = platform.script.toFilePath();
+ final String checkoutsDirname = fileSystem.path.normalize(
+ fileSystem.path.join(
+ fileSystem.path.dirname(filePath), // flutter/dev/conductor/core/bin
+ '..', // flutter/dev/conductor/core
+ '..', // flutter/dev/conductor
+ '..', // flutter/dev
+ '..', // flutter
+ ),
+ );
+ return fileSystem.directory(checkoutsDirname);
+}
diff --git a/dev/conductor/core/lib/packages_autoroller.dart b/dev/conductor/core/lib/packages_autoroller.dart
new file mode 100644
index 0000000..d668027
--- /dev/null
+++ b/dev/conductor/core/lib/packages_autoroller.dart
@@ -0,0 +1,5 @@
+// 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.
+
+export 'src/packages_autoroller.dart';
diff --git a/dev/conductor/core/lib/src/packages_autoroller.dart b/dev/conductor/core/lib/src/packages_autoroller.dart
new file mode 100644
index 0000000..e05d494
--- /dev/null
+++ b/dev/conductor/core/lib/src/packages_autoroller.dart
@@ -0,0 +1,208 @@
+// 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:convert';
+import 'dart:io' as io;
+
+import 'package:process/process.dart';
+
+import 'git.dart';
+import 'globals.dart';
+import 'repository.dart';
+
+/// A service for rolling the SDK's pub packages to latest and open a PR upstream.
+class PackageAutoroller {
+ PackageAutoroller({
+ required this.githubClient,
+ required this.token,
+ required this.framework,
+ required this.orgName,
+ required this.processManager,
+ }) {
+ if (token.trim().isEmpty) {
+ throw Exception('empty token!');
+ }
+ if (githubClient.trim().isEmpty) {
+ throw Exception('Must provide path to GitHub client!');
+ }
+ if (orgName.trim().isEmpty) {
+ throw Exception('Must provide an orgName!');
+ }
+ }
+
+ final FrameworkRepository framework;
+ final ProcessManager processManager;
+
+ /// Path to GitHub CLI client.
+ final String githubClient;
+
+ /// GitHub API access token.
+ final String token;
+
+ static const String hostname = 'github.com';
+
+ static const String prBody = '''
+This PR was generated by `flutter update-packages --force-upgrade`.
+''';
+
+ /// Name of the feature branch to be opened on against the mirror repo.
+ ///
+ /// We never re-use a previous branch, so the branch name ends in an index
+ /// number, which gets incremented for each roll.
+ late final Future<String> featureBranchName = (() async {
+ final List<String> remoteBranches = await framework.listRemoteBranches(framework.mirrorRemote!.name);
+
+ int x = 1;
+ String name(int index) => 'packages-autoroller-branch-$index';
+
+ while (remoteBranches.contains(name(x))) {
+ x += 1;
+ }
+
+ return name(x);
+ })();
+
+ /// Name of the GitHub organization to push the feature branch to.
+ final String orgName;
+
+ Future<void> roll() async {
+ await authLogin();
+ await updatePackages();
+ await pushBranch();
+ await createPr(
+ repository: await framework.checkoutDirectory,
+ );
+ await authLogout();
+ }
+
+ Future<void> updatePackages({
+ bool verbose = true,
+ String author = 'flutter-packages-autoroller <flutter-packages-autoroller@google.com>'
+ }) async {
+ await framework.newBranch(await featureBranchName);
+ final io.Process flutterProcess = await framework.streamFlutter(<String>[
+ if (verbose) '--verbose',
+ 'update-packages',
+ '--force-upgrade',
+ ]);
+ final int exitCode = await flutterProcess.exitCode;
+ if (exitCode != 0) {
+ throw ConductorException('Failed to update packages with exit code $exitCode');
+ }
+ await framework.commit(
+ 'roll packages',
+ addFirst: true,
+ author: author,
+ );
+ }
+
+ Future<void> pushBranch() async {
+ await framework.pushRef(
+ fromRef: await featureBranchName,
+ toRef: await featureBranchName,
+ remote: framework.mirrorRemote!.url,
+ );
+ }
+
+ Future<void> authLogout() {
+ return cli(
+ <String>['auth', 'logout', '--hostname', hostname],
+ allowFailure: true,
+ );
+ }
+
+ Future<void> authLogin() {
+ return cli(
+ <String>[
+ 'auth',
+ 'login',
+ '--hostname',
+ hostname,
+ '--git-protocol',
+ 'https',
+ '--with-token',
+ ],
+ stdin: token,
+ );
+ }
+
+ /// Create a pull request on GitHub.
+ ///
+ /// Depends on the gh cli tool.
+ Future<void> createPr({
+ required io.Directory repository,
+ String title = 'Roll pub packages',
+ String body = 'This PR was generated by `flutter update-packages --force-upgrade`.',
+ String base = FrameworkRepository.defaultBranch,
+ bool draft = false,
+ }) async {
+ // We will wrap title and body in double quotes before delegating to gh
+ // binary
+ await cli(
+ <String>[
+ 'pr',
+ 'create',
+ '--title',
+ title.trim(),
+ '--body',
+ body.trim(),
+ '--head',
+ '$orgName:${await featureBranchName}',
+ '--base',
+ base,
+ if (draft)
+ '--draft',
+ ],
+ workingDirectory: repository.path,
+ );
+ }
+
+ Future<void> help([List<String>? args]) {
+ return cli(<String>[
+ 'help',
+ ...?args,
+ ]);
+ }
+
+ Future<void> cli(
+ List<String> args, {
+ bool allowFailure = false,
+ String? stdin,
+ String? workingDirectory,
+ }) async {
+ print('Executing "$githubClient ${args.join(' ')}" in $workingDirectory');
+ final io.Process process = await processManager.start(
+ <String>[githubClient, ...args],
+ workingDirectory: workingDirectory,
+ environment: <String, String>{},
+ );
+ final List<String> stderrStrings = <String>[];
+ final List<String> stdoutStrings = <String>[];
+ final Future<void> stdoutFuture = process.stdout
+ .transform(utf8.decoder)
+ .forEach(stdoutStrings.add);
+ final Future<void> stderrFuture = process.stderr
+ .transform(utf8.decoder)
+ .forEach(stderrStrings.add);
+ if (stdin != null) {
+ process.stdin.write(stdin);
+ await process.stdin.flush();
+ await process.stdin.close();
+ }
+ final int exitCode = await process.exitCode;
+ await Future.wait(<Future<Object?>>[
+ stdoutFuture,
+ stderrFuture,
+ ]);
+ final String stderr = stderrStrings.join();
+ final String stdout = stdoutStrings.join();
+ if (!allowFailure && exitCode != 0) {
+ throw GitException(
+ '$stderr\n$stdout',
+ args,
+ );
+ }
+ print(stdout);
+ }
+}
diff --git a/dev/conductor/core/lib/src/repository.dart b/dev/conductor/core/lib/src/repository.dart
index 71a382b..eff3fa1 100644
--- a/dev/conductor/core/lib/src/repository.dart
+++ b/dev/conductor/core/lib/src/repository.dart
@@ -2,7 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:convert' show jsonDecode;
+import 'dart:async';
+import 'dart:convert';
import 'dart:io' as io;
import 'package:file/file.dart';
@@ -30,6 +31,20 @@
assert(url != null),
assert(url != '');
+ factory Remote.mirror(String url) {
+ return Remote(
+ name: RemoteName.mirror,
+ url: url,
+ );
+ }
+
+ factory Remote.upstream(String url) {
+ return Remote(
+ name: RemoteName.upstream,
+ url: url,
+ );
+ }
+
final RemoteName _name;
/// The name of the remote.
@@ -134,6 +149,37 @@
return _checkoutDirectory!;
}
+ /// RegExp pattern to parse the output of git ls-remote.
+ ///
+ /// Git output looks like:
+ ///
+ /// 35185330c6af3a435f615ee8ac2fed8b8bb7d9d4 refs/heads/95159-squash
+ /// 6f60a1e7b2f3d2c2460c9dc20fe54d0e9654b131 refs/heads/add-debug-trace
+ /// c1436c42c0f3f98808ae767e390c3407787f1a67 refs/heads/add-recipe-field
+ /// 4d44dca340603e25d4918c6ef070821181202e69 refs/heads/add-release-channel
+ ///
+ /// We are interested in capturing what comes after 'refs/heads/'.
+ static final RegExp _lsRemotePattern = RegExp(r'.*\s+refs\/heads\/([^\s]+)$');
+
+ /// Parse git ls-remote --heads and return branch names.
+ Future<List<String>> listRemoteBranches(String remote) async {
+ final String output = await git.getOutput(
+ <String>['ls-remote', '--heads', remote],
+ 'get remote branches',
+ workingDirectory: (await checkoutDirectory).path,
+ );
+
+ final List<String> remoteBranches = <String>[];
+ for (final String line in output.split('\n')) {
+ final RegExpMatch? match = _lsRemotePattern.firstMatch(line);
+ if (match != null) {
+ remoteBranches.add(match.group(1)!);
+ }
+ }
+
+ return remoteBranches;
+ }
+
/// Ensure the repository is cloned to disk and initialized with proper state.
Future<void> lazilyInitialize(Directory checkoutDirectory) async {
if (checkoutDirectory.existsSync()) {
@@ -408,8 +454,8 @@
Future<String> commit(
String message, {
bool addFirst = false,
+ String? author,
}) async {
- assert(!message.contains("'"));
final bool hasChanges = (await git.getOutput(
<String>['status', '--porcelain'],
'check for uncommitted changes',
@@ -426,8 +472,28 @@
workingDirectory: (await checkoutDirectory).path,
);
}
+ String? authorArg;
+ if (author != null) {
+ if (author.contains('"')) {
+ throw FormatException(
+ 'Commit author cannot contain character \'"\', received $author',
+ );
+ }
+ // verify [author] matches git author convention, e.g. "Jane Doe <jane.doe@email.com>"
+ if (!RegExp(r'.+<.*>').hasMatch(author)) {
+ throw FormatException(
+ 'Commit author appears malformed: "$author"',
+ );
+ }
+ authorArg = '--author="$author"';
+ }
await git.run(
- <String>['commit', "--message='$message'"],
+ <String>[
+ 'commit',
+ '--message',
+ message,
+ if (authorArg != null) authorArg,
+ ],
'commit changes',
workingDirectory: (await checkoutDirectory).path,
);
@@ -590,6 +656,29 @@
]);
}
+ Future<io.Process> streamFlutter(
+ List<String> args, {
+ void Function(String)? stdoutCallback,
+ void Function(String)? stderrCallback,
+ }) async {
+ await _ensureToolReady();
+ final io.Process process = await processManager.start(<String>[
+ fileSystem.path.join((await checkoutDirectory).path, 'bin', 'flutter'),
+ ...args,
+ ]);
+ process
+ .stdout
+ .transform(utf8.decoder)
+ .transform(const LineSplitter())
+ .listen(stdoutCallback ?? stdio.printTrace);
+ process
+ .stderr
+ .transform(utf8.decoder)
+ .transform(const LineSplitter())
+ .listen(stderrCallback ?? stdio.printError);
+ return process;
+ }
+
@override
Future<void> checkout(String ref) async {
await super.checkout(ref);
diff --git a/dev/conductor/core/test/common.dart b/dev/conductor/core/test/common.dart
index b661271..9cf66ff 100644
--- a/dev/conductor/core/test/common.dart
+++ b/dev/conductor/core/test/common.dart
@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-
import 'package:args/args.dart';
import 'package:conductor_core/src/stdio.dart';
import 'package:test/test.dart';
diff --git a/dev/conductor/core/test/next_test.dart b/dev/conductor/core/test/next_test.dart
index e951a29..787b785 100644
--- a/dev/conductor/core/test/next_test.dart
+++ b/dev/conductor/core/test/next_test.dart
@@ -31,7 +31,7 @@
const String releaseChannel = 'beta';
const String stateFile = '/state-file.json';
final String localPathSeparator = const LocalPlatform().pathSeparator;
- final String localOperatingSystem = const LocalPlatform().pathSeparator;
+ final String localOperatingSystem = const LocalPlatform().operatingSystem;
group('next command', () {
late MemoryFileSystem fileSystem;
@@ -502,7 +502,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='Create candidate branch version $candidateBranch for $releaseChannel'",
+ '--message',
+ 'Create candidate branch version $candidateBranch for $releaseChannel',
]),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -516,7 +517,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'",
+ '--message',
+ 'Update Engine revision to $revision1 for $releaseChannel release $releaseVersion',
]),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -593,7 +595,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='Create candidate branch version $candidateBranch for $releaseChannel'",
+ '--message',
+ 'Create candidate branch version $candidateBranch for $releaseChannel',
]),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -607,7 +610,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'",
+ '--message',
+ 'Update Engine revision to $revision1 for $releaseChannel release $releaseVersion',
]),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -671,7 +675,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='Create candidate branch version $candidateBranch for $releaseChannel'",
+ '--message',
+ 'Create candidate branch version $candidateBranch for $releaseChannel',
]),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -685,7 +690,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'",
+ '--message',
+ 'Update Engine revision to $revision1 for $releaseChannel release $releaseVersion',
]),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
diff --git a/dev/conductor/core/test/packages_autoroller_test.dart b/dev/conductor/core/test/packages_autoroller_test.dart
new file mode 100644
index 0000000..9799261
--- /dev/null
+++ b/dev/conductor/core/test/packages_autoroller_test.dart
@@ -0,0 +1,193 @@
+// 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' as io;
+
+import 'package:conductor_core/conductor_core.dart';
+import 'package:conductor_core/packages_autoroller.dart';
+import 'package:file/memory.dart';
+import 'package:platform/platform.dart';
+
+import './common.dart';
+
+void main() {
+ const String flutterRoot = '/flutter';
+ const String checkoutsParentDirectory = '$flutterRoot/dev/conductor';
+ const String githubClient = 'gh';
+ const String token = '0123456789abcdef';
+ const String orgName = 'flutter-roller';
+ const String mirrorUrl = 'https://githost.com/flutter-roller/flutter.git';
+ final String localPathSeparator = const LocalPlatform().pathSeparator;
+ final String localOperatingSystem = const LocalPlatform().operatingSystem;
+ late MemoryFileSystem fileSystem;
+ late TestStdio stdio;
+ late FrameworkRepository framework;
+ late PackageAutoroller autoroller;
+ late FakeProcessManager processManager;
+
+ setUp(() {
+ stdio = TestStdio();
+ fileSystem = MemoryFileSystem.test();
+ processManager = FakeProcessManager.empty();
+ final FakePlatform platform = FakePlatform(
+ environment: <String, String>{
+ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator),
+ },
+ operatingSystem: localOperatingSystem,
+ pathSeparator: localPathSeparator,
+ );
+ final Checkouts checkouts = Checkouts(
+ fileSystem: fileSystem,
+ parentDirectory: fileSystem.directory(checkoutsParentDirectory)
+ ..createSync(recursive: true),
+ platform: platform,
+ processManager: processManager,
+ stdio: stdio,
+ );
+ framework = FrameworkRepository(
+ checkouts,
+ mirrorRemote: const Remote(
+ name: RemoteName.mirror,
+ url: mirrorUrl,
+ ),
+ );
+
+ autoroller = PackageAutoroller(
+ githubClient: githubClient,
+ token: token,
+ framework: framework,
+ orgName: orgName,
+ processManager: processManager,
+ );
+ });
+
+ test('can roll with correct inputs', () async {
+ final StreamController<List<int>> controller =
+ StreamController<List<int>>();
+ processManager.addCommands(<FakeCommand>[
+ FakeCommand(command: const <String>[
+ 'gh',
+ 'auth',
+ 'login',
+ '--hostname',
+ 'github.com',
+ '--git-protocol',
+ 'https',
+ '--with-token',
+ ], stdin: io.IOSink(controller.sink)),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'clone',
+ '--origin',
+ 'upstream',
+ '--',
+ FrameworkRepository.defaultUpstream,
+ '$checkoutsParentDirectory/flutter_conductor_checkouts/framework',
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'remote',
+ 'add',
+ 'mirror',
+ mirrorUrl,
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'fetch',
+ 'mirror',
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'checkout',
+ FrameworkRepository.defaultBranch,
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'rev-parse',
+ 'HEAD',
+ ], stdout: 'deadbeef'),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'ls-remote',
+ '--heads',
+ 'mirror',
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'checkout',
+ '-b',
+ 'packages-autoroller-branch-1',
+ ]),
+ const FakeCommand(command: <String>[
+ '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter',
+ 'help',
+ ]),
+ const FakeCommand(command: <String>[
+ '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter',
+ '--verbose',
+ 'update-packages',
+ '--force-upgrade',
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'status',
+ '--porcelain',
+ ], stdout: '''
+ M packages/foo/pubspec.yaml
+ M packages/bar/pubspec.yaml
+ M dev/integration_tests/test_foo/pubspec.yaml
+'''),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'add',
+ '--all',
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'commit',
+ '--message',
+ 'roll packages',
+ '--author="flutter-packages-autoroller <flutter-packages-autoroller@google.com>"',
+ ]),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'rev-parse',
+ 'HEAD',
+ ], stdout: '000deadbeef'),
+ const FakeCommand(command: <String>[
+ 'git',
+ 'push',
+ mirrorUrl,
+ 'packages-autoroller-branch-1:packages-autoroller-branch-1',
+ ]),
+ const FakeCommand(command: <String>[
+ 'gh',
+ 'pr',
+ 'create',
+ '--title',
+ 'Roll pub packages',
+ '--body',
+ 'This PR was generated by `flutter update-packages --force-upgrade`.',
+ '--head',
+ 'flutter-roller:packages-autoroller-branch-1',
+ '--base',
+ FrameworkRepository.defaultBranch,
+ ]),
+ const FakeCommand(command: <String>[
+ 'gh',
+ 'auth',
+ 'logout',
+ '--hostname',
+ 'github.com',
+ ]),
+ ]);
+ final Future<void> rollFuture = autoroller.roll();
+ final String givenToken =
+ await controller.stream.transform(const Utf8Decoder()).join();
+ expect(givenToken, token);
+ await rollFuture;
+ });
+}
diff --git a/dev/conductor/core/test/repository_test.dart b/dev/conductor/core/test/repository_test.dart
index c6b95a2..35196b8 100644
--- a/dev/conductor/core/test/repository_test.dart
+++ b/dev/conductor/core/test/repository_test.dart
@@ -13,6 +13,7 @@
group('repository', () {
late FakePlatform platform;
const String rootDir = '/';
+ const String revision = 'deadbeef';
late MemoryFileSystem fileSystem;
late FakeProcessManager processManager;
late TestStdio stdio;
@@ -31,8 +32,6 @@
});
test('canCherryPick returns true if git cherry-pick returns 0', () async {
- const String commit = 'abc123';
-
processManager.addCommands(<FakeCommand>[
FakeCommand(command: <String>[
'git',
@@ -53,7 +52,7 @@
'git',
'rev-parse',
'HEAD',
- ], stdout: commit),
+ ], stdout: revision),
const FakeCommand(command: <String>[
'git',
'status',
@@ -63,7 +62,7 @@
'git',
'cherry-pick',
'--no-commit',
- commit,
+ revision,
]),
const FakeCommand(command: <String>[
'git',
@@ -80,7 +79,7 @@
stdio: stdio,
);
final Repository repository = FrameworkRepository(checkouts);
- expect(await repository.canCherryPick(commit), true);
+ expect(await repository.canCherryPick(revision), true);
});
test('canCherryPick returns false if git cherry-pick returns non-zero', () async {
@@ -262,7 +261,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='$message'",
+ '--message',
+ message,
]),
const FakeCommand(command: <String>[
'git',
@@ -318,7 +318,8 @@
const FakeCommand(command: <String>[
'git',
'commit',
- "--message='$message'",
+ '--message',
+ message,
]),
const FakeCommand(command: <String>[
'git',
@@ -501,6 +502,66 @@
expect(processManager.hasRemainingExpectations, false);
expect(createdCandidateBranch, true);
});
+
+ test('.listRemoteBranches() parses git output', () async {
+ const String remoteName = 'mirror';
+ const String lsRemoteOutput = '''
+Extraneous debug information that should be ignored.
+
+4d44dca340603e25d4918c6ef070821181202e69 refs/heads/experiment
+35185330c6af3a435f615ee8ac2fed8b8bb7d9d4 refs/heads/feature-a
+6f60a1e7b2f3d2c2460c9dc20fe54d0e9654b131 refs/heads/feature-b
+c1436c42c0f3f98808ae767e390c3407787f1a67 refs/heads/fix_bug_1234
+bbbcae73699263764ad4421a4b2ca3952a6f96cb refs/heads/stable
+
+Extraneous debug information that should be ignored.
+''';
+ processManager.addCommands(const <FakeCommand>[
+ FakeCommand(command: <String>[
+ 'git',
+ 'clone',
+ '--origin',
+ 'upstream',
+ '--',
+ EngineRepository.defaultUpstream,
+ '${rootDir}flutter_conductor_checkouts/engine',
+ ]),
+ FakeCommand(command: <String>[
+ 'git',
+ 'checkout',
+ 'main',
+ ]),
+ FakeCommand(command: <String>[
+ 'git',
+ 'rev-parse',
+ 'HEAD',
+ ], stdout: revision),
+ FakeCommand(
+ command: <String>['git', 'ls-remote', '--heads', remoteName],
+ stdout: lsRemoteOutput,
+ ),
+ ]);
+ final Checkouts checkouts = Checkouts(
+ fileSystem: fileSystem,
+ parentDirectory: fileSystem.directory(rootDir),
+ platform: platform,
+ processManager: processManager,
+ stdio: stdio,
+ );
+
+ final Repository repo = EngineRepository(
+ checkouts,
+ localUpstream: true,
+ );
+ final List<String> branchNames = await repo.listRemoteBranches(remoteName);
+ expect(branchNames, equals(<String>[
+ 'experiment',
+ 'feature-a',
+ 'feature-b',
+ 'fix_bug_1234',
+ 'stable',
+ ]));
+ });
});
}
diff --git a/dev/conductor/core/test/start_test.dart b/dev/conductor/core/test/start_test.dart
index b9e78b7..0945cbf 100644
--- a/dev/conductor/core/test/start_test.dart
+++ b/dev/conductor/core/test/start_test.dart
@@ -212,7 +212,7 @@
command: <String>['git', 'add', '--all'],
),
const FakeCommand(
- command: <String>['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"],
+ command: <String>['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'],
),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -400,7 +400,7 @@
command: <String>['git', 'add', '--all'],
),
const FakeCommand(
- command: <String>['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"],
+ command: <String>['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'],
),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -574,7 +574,7 @@
command: <String>['git', 'add', '--all'],
),
const FakeCommand(
- command: <String>['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"],
+ command: <String>['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'],
),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -761,7 +761,7 @@
command: <String>['git', 'add', '--all'],
),
const FakeCommand(
- command: <String>['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"],
+ command: <String>['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'],
),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],
@@ -952,7 +952,7 @@
command: <String>['git', 'add', '--all'],
),
const FakeCommand(
- command: <String>['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"],
+ command: <String>['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'],
),
const FakeCommand(
command: <String>['git', 'rev-parse', 'HEAD'],