blob: 30fb4d57ff5d21b4568d4d44572d4dd6a772b1b1 [file] [log] [blame]
// 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:file/file.dart';
import 'package:meta/meta.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
import 'git.dart';
import 'stdio.dart';
/// Allowed git remote names.
enum RemoteName { upstream, mirror }
/// Git remote locations.
final class Remote {
const Remote._({required RemoteName name, required this.url}) : _name = name, assert(url != '');
const Remote.mirror(String url) : this._(name: RemoteName.mirror, url: url);
const Remote.upstream(String url) : this._(name: RemoteName.upstream, url: url);
final RemoteName _name;
/// The name of the remote.
String get name => switch (_name) {
RemoteName.upstream => 'upstream',
RemoteName.mirror => 'mirror',
};
/// The URL of the remote.
final String url;
}
/// A source code repository.
///
/// This class is an abstraction over a git
/// repository on the local disk. Ideally this abstraction would hide from
/// the outside libraries what git calls were needed to either read or update
/// data in the underlying repository. In practice, most of the bugs in the
/// conductor codebase are related to the git calls made from this and its
/// subclasses.
///
/// Two factors that make this code more complicated than it would otherwise
/// need to be are:
/// 1. That any particular invocation of the conductor may or may not already
/// have the git checkout present on disk, depending on what commands were
/// previously run; and
/// 2. The need to provide overrides for integration tests (in particular
/// the ability to mark a [Repository] instance as a [localUpstream] made
/// integration tests more hermetic, at the cost of complexity in the
/// implementation).
///
/// The only way to simplify the first factor would be to change the behavior of
/// the conductor tool to be a long-lived dart process that keeps all of its
/// state in memory and blocks on user input. This would add the constraint that
/// the user would need to keep the process running for the duration of a
/// release, which could potentially take multiple days and users could not
/// manually change the state of the release process (via editing the JSON
/// config file). However, these may be reasonable trade-offs to make the
/// codebase simpler and easier to reason about.
///
/// The way to simplify the second factor would be to not put any special
/// handling in this library for integration tests. This would make integration
/// tests more difficult/less hermetic, but the production code more reliable.
/// This is probably the right trade-off to make, as the integration tests were
/// still not hermetic or reliable, and the main integration test was ultimately
/// deleted in #84354.
abstract class Repository {
Repository({
required this.name,
required this.upstreamRemote,
required this.processManager,
required this.stdio,
required this.platform,
required this.fileSystem,
required this.parentDirectory,
this.initialRef,
String? previousCheckoutLocation,
required this.mirrorRemote,
}) : _previousCheckoutLocation = previousCheckoutLocation,
git = Git(processManager),
assert(upstreamRemote.url.isNotEmpty);
final String name;
final Remote upstreamRemote;
/// Remote for user's mirror.
final Remote mirrorRemote;
/// The initial ref (branch or commit name) to check out.
final String? initialRef;
final Git git;
final ProcessManager processManager;
final Stdio stdio;
final Platform platform;
final FileSystem fileSystem;
final Directory parentDirectory;
Directory? _checkoutDirectory;
final String? _previousCheckoutLocation;
/// Directory for the repository checkout.
///
/// Since cloning a repository takes a long time, we do not ensure it is
/// cloned on the filesystem until this getter is accessed.
Future<Directory> get checkoutDirectory async {
if (_checkoutDirectory != null) {
return _checkoutDirectory!;
}
if (_previousCheckoutLocation != null) {
_checkoutDirectory = fileSystem.directory(_previousCheckoutLocation);
if (!_checkoutDirectory!.existsSync()) {
throw Exception(
'Provided previousCheckoutLocation $_previousCheckoutLocation does not exist on disk!',
);
}
if (initialRef != null) {
assert(initialRef != '');
await git.run(
<String>['fetch', upstreamRemote.name],
'Fetch ${upstreamRemote.name} to ensure we have latest refs',
workingDirectory: _checkoutDirectory!.path,
);
// If [initialRef] is a remote ref, the checkout will be left in a detached HEAD state.
await git.run(
<String>['checkout', initialRef!],
'Checking out initialRef $initialRef',
workingDirectory: _checkoutDirectory!.path,
);
}
return _checkoutDirectory!;
}
_checkoutDirectory = parentDirectory.childDirectory(name);
await _lazilyInitialize(_checkoutDirectory!);
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,
);
return <String>[
for (final String line in output.split('\n'))
if (_lsRemotePattern.firstMatch(line) case final RegExpMatch match) match.group(1)!,
];
}
/// Ensure the repository is cloned to disk and initialized with proper state.
Future<void> _lazilyInitialize(Directory checkoutDirectory) async {
if (checkoutDirectory.existsSync()) {
stdio.printTrace('Deleting $name from ${checkoutDirectory.path}...');
checkoutDirectory.deleteSync(recursive: true);
}
stdio.printTrace('Cloning $name from ${upstreamRemote.url} to ${checkoutDirectory.path}...');
await git.run(
<String>[
'clone',
'--origin',
upstreamRemote.name,
'--',
upstreamRemote.url,
checkoutDirectory.path,
],
'Cloning $name repo',
workingDirectory: parentDirectory.path,
);
await git.run(
<String>['remote', 'add', mirrorRemote.name, mirrorRemote.url],
'Adding remote ${mirrorRemote.url} as ${mirrorRemote.name}',
workingDirectory: checkoutDirectory.path,
);
await git.run(
<String>['fetch', mirrorRemote.name],
'Fetching git remote ${mirrorRemote.name}',
workingDirectory: checkoutDirectory.path,
);
if (initialRef != null) {
await git.run(
<String>['checkout', initialRef!],
'Checking out initialRef $initialRef',
workingDirectory: checkoutDirectory.path,
);
}
final String revision = await _reverseParse('HEAD');
stdio.printTrace('Repository $name is checked out at revision "$revision".');
}
/// Get the working tree status.
///
/// Calls `git status --porcelain` which should output in a stable format
/// across git versions.
Future<String> gitStatus() async {
return git.getOutput(
<String>['status', '--porcelain'],
'check that the git checkout is clean',
workingDirectory: (await checkoutDirectory).path,
);
}
/// Verify the repository's git checkout is clean.
Future<bool> gitCheckoutClean() async {
return (await gitStatus()).isEmpty;
}
/// Create (and checkout) a new branch based on the current HEAD.
///
/// Runs `git checkout -b $branchName`.
Future<void> newBranch(String branchName) async {
await git.run(
<String>['checkout', '-b', branchName],
'create & checkout new branch $branchName',
workingDirectory: (await checkoutDirectory).path,
);
}
/// Look up the commit for [ref].
Future<String> _reverseParse(String ref) async {
final String revisionHash = await git.getOutput(
<String>['rev-parse', ref],
'look up the commit for the ref $ref',
workingDirectory: (await checkoutDirectory).path,
);
assert(revisionHash.isNotEmpty);
return revisionHash;
}
/// Push [commit] to the release channel [branch].
Future<void> pushRef({
required String fromRef,
required String remote,
required String toRef,
bool force = false,
bool dryRun = false,
}) async {
final List<String> args = <String>['push', if (force) '--force', remote, '$fromRef:$toRef'];
final String command = <String>['git', ...args].join(' ');
if (dryRun) {
stdio.printStatus('About to execute command: `$command`');
} else {
await git.run(
args,
'update the release branch with the commit',
workingDirectory: (await checkoutDirectory).path,
);
stdio.printStatus('Executed command: `$command`');
}
}
Future<String> commit(String message, {bool addFirst = false, String? author}) async {
if (addFirst) {
final bool hasChanges = (await git.getOutput(
<String>['status', '--porcelain'],
'check for uncommitted changes',
workingDirectory: (await checkoutDirectory).path,
)).trim().isNotEmpty;
if (!hasChanges) {
throw Exception('Tried to commit with message $message but no changes were present');
}
await git.run(
<String>['add', '--all'],
'add all changes to the index',
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"';
}
final List<String> commitCmd = <String>['commit', '--message', message, ?authorArg];
stdio.printTrace('Executing git $commitCmd...');
final io.ProcessResult commitResult = await git.run(
commitCmd,
'commit changes',
workingDirectory: (await checkoutDirectory).path,
);
final String stdout = commitResult.stdout as String;
if (stdout.isNotEmpty) {
stdio.printTrace(stdout);
}
final String stderr = commitResult.stderr as String;
if (stderr.isNotEmpty) {
stdio.printTrace(stderr);
}
return _reverseParse('HEAD');
}
}
final class FrameworkRepository extends Repository {
FrameworkRepository(
this.checkouts, {
super.name = 'framework',
super.upstreamRemote = const Remote.upstream(FrameworkRepository.defaultUpstream),
super.previousCheckoutLocation,
String super.initialRef = FrameworkRepository.defaultBranch,
required super.mirrorRemote,
}) : super(
fileSystem: checkouts.fileSystem,
parentDirectory: checkouts.directory,
platform: checkouts.platform,
processManager: checkouts.processManager,
stdio: checkouts.stdio,
);
final Checkouts checkouts;
static const String defaultUpstream = 'git@github.com:flutter/flutter.git';
static const String defaultBranch = 'master';
Future<void> streamDart(List<String> args, {String? workingDirectory}) async {
final String repoWorkingDirectory = (await checkoutDirectory).path;
await _streamProcess(<String>[
fileSystem.path.join(repoWorkingDirectory, 'bin', 'dart'),
...args,
], workingDirectory: workingDirectory ?? repoWorkingDirectory);
}
Future<io.Process> streamFlutter(
List<String> args, {
void Function(String)? stdoutCallback,
void Function(String)? stderrCallback,
String? workingDirectory,
}) async {
final String repoWorkingDirectory = (await checkoutDirectory).path;
return _streamProcess(<String>[
fileSystem.path.join(repoWorkingDirectory, 'bin', 'flutter'),
...args,
], workingDirectory: workingDirectory ?? repoWorkingDirectory);
}
Future<io.Process> _streamProcess(
List<String> cmd, {
void Function(String)? stdoutCallback,
void Function(String)? stderrCallback,
String? workingDirectory,
}) async {
stdio.printTrace('Executing $cmd...');
final io.Process process = await processManager.start(cmd, workingDirectory: workingDirectory);
final StreamSubscription<String> stdoutSub = process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(stdoutCallback ?? stdio.printTrace);
final StreamSubscription<String> stderrSub = process.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(stderrCallback ?? stdio.printError);
await Future.wait<void>(<Future<void>>[stdoutSub.asFuture<void>(), stderrSub.asFuture<void>()]);
unawaited(stdoutSub.cancel());
unawaited(stderrSub.cancel());
final int exitCode = await process.exitCode;
if (exitCode != 0) {
throw io.ProcessException(cmd.first, cmd.sublist(1), 'Process failed', exitCode);
}
return process;
}
}
/// Represents the environment in which a command is being executed.
@immutable
final class Checkouts {
Checkouts({
required this.platform,
required this.processManager,
required this.stdio,
required Directory parentDirectory,
String directoryName = 'package_autoroller_checkouts',
}) : directory = parentDirectory.childDirectory(directoryName) {
if (!directory.existsSync()) {
directory.createSync(recursive: true);
}
}
/// Which directory is be used to checkout code.
final Directory directory;
/// The file system used to access the checkout path.
FileSystem get fileSystem => directory.fileSystem;
/// The platform being executed on.
final Platform platform;
/// Ability to spawn processes on the current system.
final ProcessManager processManager;
/// Standard I/O facade.
final Stdio stdio;
}