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'],