blob: 07aeab476f1c49fbe08204d698be8fcaa7a3b2a1 [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:convert' show jsonDecode;
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 './globals.dart';
import './stdio.dart';
import './version.dart';
/// Allowed git remote names.
enum RemoteName {
upstream,
mirror,
}
class Remote {
const Remote({
required RemoteName name,
required this.url,
}) : _name = name,
assert(url != null),
assert(url != '');
final RemoteName _name;
/// The name of the remote.
String get name {
switch (_name) {
case RemoteName.upstream:
return 'upstream';
case RemoteName.mirror:
return 'mirror';
}
}
/// The URL of the remote.
final String url;
}
/// A source code repository.
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,
this.localUpstream = false,
String? previousCheckoutLocation,
this.mirrorRemote,
}) : git = Git(processManager),
assert(localUpstream != null),
assert(upstreamRemote.url.isNotEmpty) {
if (previousCheckoutLocation != null) {
_checkoutDirectory = fileSystem.directory(previousCheckoutLocation);
if (!_checkoutDirectory!.existsSync()) {
throw ConductorException(
'Provided previousCheckoutLocation $previousCheckoutLocation does not exist on disk!');
}
if (initialRef != null) {
git.run(
<String>['fetch', upstreamRemote.name],
'Fetch ${upstreamRemote.name} to ensure we have latest refs',
workingDirectory: _checkoutDirectory!.path,
);
// Note: if [initialRef] is a remote ref the checkout will be left in a
// detached HEAD state.
git.run(
<String>['checkout', initialRef!],
'Checking out initialRef $initialRef',
workingDirectory: _checkoutDirectory!.path,
);
}
}
}
final String name;
final Remote upstreamRemote;
/// Remote for user's mirror.
///
/// This value can be null, in which case attempting to access it will lead to
/// a [ConductorException].
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;
/// If the repository will be used as an upstream for a test repo.
final bool localUpstream;
Directory? _checkoutDirectory;
/// 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.
Directory get checkoutDirectory {
if (_checkoutDirectory != null) {
return _checkoutDirectory!;
}
_checkoutDirectory = parentDirectory.childDirectory(name);
lazilyInitialize(_checkoutDirectory!);
return _checkoutDirectory!;
}
/// Ensure the repository is cloned to disk and initialized with proper state.
void lazilyInitialize(Directory checkoutDirectory) {
if (checkoutDirectory.existsSync()) {
stdio.printTrace('Deleting $name from ${checkoutDirectory.path}...');
checkoutDirectory.deleteSync(recursive: true);
}
stdio.printTrace(
'Cloning $name from ${upstreamRemote.url} to ${checkoutDirectory.path}...',
);
git.run(
<String>[
'clone',
'--origin',
upstreamRemote.name,
'--',
upstreamRemote.url,
checkoutDirectory.path
],
'Cloning $name repo',
workingDirectory: parentDirectory.path,
);
if (mirrorRemote != null) {
git.run(
<String>['remote', 'add', mirrorRemote!.name, mirrorRemote!.url],
'Adding remote ${mirrorRemote!.url} as ${mirrorRemote!.name}',
workingDirectory: checkoutDirectory.path,
);
git.run(
<String>['fetch', mirrorRemote!.name],
'Fetching git remote ${mirrorRemote!.name}',
workingDirectory: checkoutDirectory.path,
);
}
if (localUpstream) {
// These branches must exist locally for the repo that depends on it
// to fetch and push to.
for (final String channel in kReleaseChannels) {
git.run(
<String>['checkout', channel, '--'],
'check out branch $channel locally',
workingDirectory: checkoutDirectory.path,
);
}
}
if (initialRef != null) {
git.run(
<String>['checkout', '${upstreamRemote.name}/$initialRef'],
'Checking out initialRef $initialRef',
workingDirectory: checkoutDirectory.path,
);
}
final String revision = reverseParse('HEAD');
stdio.printTrace(
'Repository $name is checked out at revision "$revision".',
);
}
/// The URL of the remote named [remoteName].
String remoteUrl(String remoteName) {
assert(remoteName != null);
return git.getOutput(
<String>['remote', 'get-url', remoteName],
'verify the URL of the $remoteName remote',
workingDirectory: checkoutDirectory.path,
);
}
/// Verify the repository's git checkout is clean.
bool gitCheckoutClean() {
final String output = git.getOutput(
<String>['status', '--porcelain'],
'check that the git checkout is clean',
workingDirectory: checkoutDirectory.path,
);
return output == '';
}
/// Return the revision for the branch point between two refs.
String branchPoint(String firstRef, String secondRef) {
return git.getOutput(
<String>['merge-base', firstRef, secondRef],
'determine the merge base between $firstRef and $secondRef',
workingDirectory: checkoutDirectory.path,
).trim();
}
/// Fetch all branches and associated commits and tags from [remoteName].
void fetch(String remoteName) {
git.run(
<String>['fetch', remoteName, '--tags'],
'fetch $remoteName --tags',
workingDirectory: checkoutDirectory.path,
);
}
/// Create (and checkout) a new branch based on the current HEAD.
///
/// Runs `git checkout -b $branchName`.
void newBranch(String branchName) {
git.run(
<String>['checkout', '-b', branchName],
'create & checkout new branch $branchName',
workingDirectory: checkoutDirectory.path,
);
}
/// Check out the given ref.
void checkout(String ref) {
git.run(
<String>['checkout', ref],
'checkout ref',
workingDirectory: checkoutDirectory.path,
);
}
/// Obtain the version tag at the tip of a release branch.
String getFullTag(
String remoteName,
String branchName, {
bool exact = true,
}) {
// includes both stable (e.g. 1.2.3) and dev tags (e.g. 1.2.3-4.5.pre)
const String glob = '*.*.*';
// describe the latest dev release
final String ref = 'refs/remotes/$remoteName/$branchName';
return git.getOutput(
<String>[
'describe',
'--match',
glob,
if (exact) '--exact-match',
'--tags',
ref,
],
'obtain last released version number',
workingDirectory: checkoutDirectory.path,
);
}
/// List commits in reverse chronological order.
List<String> revList(List<String> args) {
return git
.getOutput(<String>['rev-list', ...args],
'rev-list with args ${args.join(' ')}',
workingDirectory: checkoutDirectory.path)
.trim()
.split('\n');
}
/// Look up the commit for [ref].
String reverseParse(String ref) {
final String revisionHash = git.getOutput(
<String>['rev-parse', ref],
'look up the commit for the ref $ref',
workingDirectory: checkoutDirectory.path,
);
assert(revisionHash.isNotEmpty);
return revisionHash;
}
/// Determines if one ref is an ancestor for another.
bool isAncestor(String possibleAncestor, String possibleDescendant) {
final int exitcode = git.run(
<String>[
'merge-base',
'--is-ancestor',
possibleDescendant,
possibleAncestor
],
'verify $possibleAncestor is a direct ancestor of $possibleDescendant.',
allowNonZeroExitCode: true,
workingDirectory: checkoutDirectory.path,
);
return exitcode == 0;
}
/// Determines if a given commit has a tag.
bool isCommitTagged(String commit) {
final int exitcode = git.run(
<String>['describe', '--exact-match', '--tags', commit],
'verify $commit is already tagged',
allowNonZeroExitCode: true,
workingDirectory: checkoutDirectory.path,
);
return exitcode == 0;
}
/// Determines if a commit will cherry-pick to current HEAD without conflict.
bool canCherryPick(String commit) {
assert(
gitCheckoutClean(),
'cannot cherry-pick because git checkout ${checkoutDirectory.path} is not clean',
);
final int exitcode = git.run(
<String>['cherry-pick', '--no-commit', commit],
'attempt to cherry-pick $commit without committing',
allowNonZeroExitCode: true,
workingDirectory: checkoutDirectory.path,
);
final bool result = exitcode == 0;
if (result == false) {
stdio.printError(git.getOutput(
<String>['diff'],
'get diff of failed cherry-pick',
workingDirectory: checkoutDirectory.path,
));
}
reset('HEAD');
return result;
}
/// Cherry-pick a [commit] to the current HEAD.
///
/// This method will throw a [GitException] if the command fails.
void cherryPick(String commit) {
assert(
gitCheckoutClean(),
'cannot cherry-pick because git checkout ${checkoutDirectory.path} is not clean',
);
git.run(
<String>['cherry-pick', commit],
'cherry-pick $commit',
workingDirectory: checkoutDirectory.path,
);
}
/// Resets repository HEAD to [ref].
void reset(String ref) {
git.run(
<String>['reset', ref, '--hard'],
'reset to $ref',
workingDirectory: checkoutDirectory.path,
);
}
/// Push [commit] to the release channel [branch].
void pushRef({
required String fromRef,
required String remote,
required String toRef,
bool force = false,
bool dryRun = false,
}) {
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 {
git.run(
args,
'update the release branch with the commit',
workingDirectory: checkoutDirectory.path,
);
stdio.printStatus('Executed command: `$command`');
}
}
String commit(
String message, {
bool addFirst = false,
}) {
assert(!message.contains("'"));
if (addFirst) {
git.run(
<String>['add', '--all'],
'add all changes to the index',
workingDirectory: checkoutDirectory.path,
);
}
git.run(
<String>['commit', "--message='$message'"],
'commit changes',
workingDirectory: checkoutDirectory.path,
);
return reverseParse('HEAD');
}
/// Create an empty commit and return the revision.
@visibleForTesting
String authorEmptyCommit([String message = 'An empty commit']) {
git.run(
<String>[
'-c',
'user.name=Conductor',
'-c',
'user.email=conductor@flutter.dev',
'commit',
'--allow-empty',
'-m',
"'$message'",
],
'create an empty commit',
workingDirectory: checkoutDirectory.path,
);
return reverseParse('HEAD');
}
/// Create a new clone of the current repository.
///
/// The returned repository will inherit all properties from this one, except
/// for the upstream, which will be the path to this repository on disk.
///
/// This method is for testing purposes.
@visibleForTesting
Repository cloneRepository(String cloneName);
}
class FrameworkRepository extends Repository {
FrameworkRepository(
this.checkouts, {
String name = 'framework',
Remote upstreamRemote = const Remote(
name: RemoteName.upstream, url: FrameworkRepository.defaultUpstream),
bool localUpstream = false,
String? previousCheckoutLocation,
String? initialRef,
Remote? mirrorRemote,
}) : super(
name: name,
upstreamRemote: upstreamRemote,
mirrorRemote: mirrorRemote,
initialRef: initialRef,
fileSystem: checkouts.fileSystem,
localUpstream: localUpstream,
parentDirectory: checkouts.directory,
platform: checkouts.platform,
processManager: checkouts.processManager,
stdio: checkouts.stdio,
previousCheckoutLocation: previousCheckoutLocation,
);
/// A [FrameworkRepository] with the host conductor's repo set as upstream.
///
/// This is useful when testing a commit that has not been merged upstream
/// yet.
factory FrameworkRepository.localRepoAsUpstream(
Checkouts checkouts, {
String name = 'framework',
String? previousCheckoutLocation,
required String upstreamPath,
}) {
return FrameworkRepository(
checkouts,
name: name,
upstreamRemote: Remote(
name: RemoteName.upstream,
url: 'file://$upstreamPath/',
),
localUpstream: false,
previousCheckoutLocation: previousCheckoutLocation,
);
}
final Checkouts checkouts;
static const String defaultUpstream =
'https://github.com/flutter/flutter.git';
static const String defaultBranch = 'master';
String get cacheDirectory => fileSystem.path.join(
checkoutDirectory.path,
'bin',
'cache',
);
/// Tag [commit] and push the tag to the remote.
void tag(String commit, String tagName, String remote) {
assert(commit.isNotEmpty);
assert(tagName.isNotEmpty);
assert(remote.isNotEmpty);
stdio.printStatus('About to tag commit $commit as $tagName...');
git.run(
<String>['tag', tagName, commit],
'tag the commit with the version label',
workingDirectory: checkoutDirectory.path,
);
stdio.printStatus('Tagging successful.');
stdio.printStatus('About to push $tagName to remote $remote...');
git.run(
<String>['push', remote, tagName],
'publish the tag to the repo',
workingDirectory: checkoutDirectory.path,
);
stdio.printStatus('Tag push successful.');
}
@override
Repository cloneRepository(String? cloneName) {
assert(localUpstream);
cloneName ??= 'clone-of-$name';
return FrameworkRepository(
checkouts,
name: cloneName,
upstreamRemote: Remote(
name: RemoteName.upstream, url: 'file://${checkoutDirectory.path}/'),
);
}
void _ensureToolReady() {
final File toolsStamp =
fileSystem.directory(cacheDirectory).childFile('flutter_tools.stamp');
if (toolsStamp.existsSync()) {
final String toolsStampHash = toolsStamp.readAsStringSync().trim();
final String repoHeadHash = reverseParse('HEAD');
if (toolsStampHash == repoHeadHash) {
return;
}
}
stdio.printTrace('Building tool...');
// Build tool
processManager.runSync(<String>[
fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'),
'help',
]);
}
io.ProcessResult runFlutter(List<String> args) {
_ensureToolReady();
return processManager.runSync(<String>[
fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'),
...args,
]);
}
@override
void checkout(String ref) {
super.checkout(ref);
// The tool will overwrite old cached artifacts, but not delete unused
// artifacts from a previous version. Thus, delete the entire cache and
// re-populate.
final Directory cache = fileSystem.directory(cacheDirectory);
if (cache.existsSync()) {
stdio.printTrace('Deleting cache...');
cache.deleteSync(recursive: true);
}
_ensureToolReady();
}
Version flutterVersion() {
// Check version
final io.ProcessResult result =
runFlutter(<String>['--version', '--machine']);
final Map<String, dynamic> versionJson = jsonDecode(
stdoutToString(result.stdout),
) as Map<String, dynamic>;
return Version.fromString(versionJson['frameworkVersion'] as String);
}
void updateEngineRevision(
String newEngine, {
@visibleForTesting File? engineVersionFile,
}) {
assert(newEngine.isNotEmpty);
engineVersionFile ??= checkoutDirectory
.childDirectory('bin')
.childDirectory('internal')
.childFile('engine.version');
assert(engineVersionFile.existsSync());
final String oldEngine = engineVersionFile.readAsStringSync();
stdio.printStatus('Updating engine revision from $oldEngine to $newEngine');
engineVersionFile.writeAsStringSync(newEngine.trim(), flush: true);
}
}
/// A wrapper around the host repository that is executing the conductor.
///
/// [Repository] methods that mutate the underlying repository will throw a
/// [ConductorException].
class HostFrameworkRepository extends FrameworkRepository {
HostFrameworkRepository({
required Checkouts checkouts,
String name = 'host-framework',
required String upstreamPath,
}) : super(
checkouts,
name: name,
upstreamRemote: Remote(
name: RemoteName.upstream,
url: 'file://$upstreamPath/',
),
localUpstream: false,
) {
_checkoutDirectory = checkouts.fileSystem.directory(upstreamPath);
}
@override
Directory get checkoutDirectory => _checkoutDirectory!;
@override
void newBranch(String branchName) {
throw ConductorException(
'newBranch not implemented for the host repository');
}
@override
void checkout(String ref) {
throw ConductorException(
'checkout not implemented for the host repository');
}
@override
String cherryPick(String commit) {
throw ConductorException(
'cherryPick not implemented for the host repository');
}
@override
String reset(String ref) {
throw ConductorException('reset not implemented for the host repository');
}
@override
void tag(String commit, String tagName, String remote) {
throw ConductorException('tag not implemented for the host repository');
}
void updateChannel(
String commit,
String remote,
String branch, {
bool force = false,
bool dryRun = false,
}) {
throw ConductorException(
'updateChannel not implemented for the host repository');
}
@override
String authorEmptyCommit([String message = 'An empty commit']) {
throw ConductorException(
'authorEmptyCommit not implemented for the host repository',
);
}
}
class EngineRepository extends Repository {
EngineRepository(
this.checkouts, {
String name = 'engine',
String initialRef = EngineRepository.defaultBranch,
Remote upstreamRemote = const Remote(
name: RemoteName.upstream, url: EngineRepository.defaultUpstream),
bool localUpstream = false,
String? previousCheckoutLocation,
Remote? mirrorRemote,
}) : super(
name: name,
upstreamRemote: upstreamRemote,
mirrorRemote: mirrorRemote,
initialRef: initialRef,
fileSystem: checkouts.fileSystem,
localUpstream: localUpstream,
parentDirectory: checkouts.directory,
platform: checkouts.platform,
processManager: checkouts.processManager,
stdio: checkouts.stdio,
previousCheckoutLocation: previousCheckoutLocation,
);
final Checkouts checkouts;
static const String defaultUpstream = 'https://github.com/flutter/engine.git';
static const String defaultBranch = 'master';
/// Update the `dart_revision` entry in the DEPS file.
void updateDartRevision(
String newRevision, {
@visibleForTesting File? depsFile,
}) {
assert(newRevision.length == 40);
depsFile ??= checkoutDirectory.childFile('DEPS');
final String fileContent = depsFile.readAsStringSync();
final RegExp dartPattern = RegExp("[ ]+'dart_revision': '([a-z0-9]{40})',");
final Iterable<RegExpMatch> allMatches =
dartPattern.allMatches(fileContent);
if (allMatches.length != 1) {
throw ConductorException(
'Unexpected content in the DEPS file at ${depsFile.path}\n'
'Expected to find pattern ${dartPattern.pattern} 1 times, but got '
'${allMatches.length}.');
}
final String updatedFileContent = fileContent.replaceFirst(
dartPattern,
" 'dart_revision': '$newRevision',",
);
depsFile.writeAsStringSync(updatedFileContent, flush: true);
}
@override
Repository cloneRepository(String? cloneName) {
assert(localUpstream);
cloneName ??= 'clone-of-$name';
return EngineRepository(
checkouts,
name: cloneName,
upstreamRemote: Remote(
name: RemoteName.upstream, url: 'file://${checkoutDirectory.path}/'),
);
}
}
/// An enum of all the repositories that the Conductor supports.
enum RepositoryType {
framework,
engine,
}
class Checkouts {
Checkouts({
required this.fileSystem,
required this.platform,
required this.processManager,
required this.stdio,
required Directory parentDirectory,
String directoryName = 'flutter_conductor_checkouts',
}) : directory = parentDirectory.childDirectory(directoryName) {
if (!directory.existsSync()) {
directory.createSync(recursive: true);
}
}
final Directory directory;
final FileSystem fileSystem;
final Platform platform;
final ProcessManager processManager;
final Stdio stdio;
}