blob: 84f70aa494f786c8912e4897bca2c472b55a586f [file] [log] [blame]
// Copyright 2013 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:io';
import 'dart:typed_data';
import 'package:process/process.dart';
import 'base/common.dart';
import 'base/file_system.dart';
import 'base/logger.dart';
import 'base/terminal.dart';
/// The default name of the migrate working directory used to stage proposed changes.
const String kDefaultMigrateStagingDirectoryName = 'migrate_staging_dir';
/// Utility class that contains methods that wrap git and other shell commands.
class MigrateUtils {
MigrateUtils({
required Logger logger,
required FileSystem fileSystem,
required ProcessManager processManager,
}) : _processManager = processManager,
_logger = logger,
_fileSystem = fileSystem;
final Logger _logger;
final FileSystem _fileSystem;
final ProcessManager _processManager;
Future<ProcessResult> _runCommand(List<String> command,
{String? workingDirectory, bool runInShell = false}) {
return _processManager.run(command,
workingDirectory: workingDirectory, runInShell: runInShell);
}
/// Calls `git diff` on two files and returns the diff as a DiffResult.
Future<DiffResult> diffFiles(File one, File two) async {
if (one.existsSync() && !two.existsSync()) {
return DiffResult(diffType: DiffType.deletion);
}
if (!one.existsSync() && two.existsSync()) {
return DiffResult(diffType: DiffType.addition);
}
final List<String> cmdArgs = <String>[
'git',
'diff',
'--no-index',
one.absolute.path,
two.absolute.path
];
final ProcessResult result = await _runCommand(cmdArgs);
// diff exits with 1 if diffs are found.
checkForErrors(result,
allowedExitCodes: <int>[0, 1],
commandDescription: 'git ${cmdArgs.join(' ')}');
return DiffResult(
diffType: DiffType.command,
diff: result.stdout as String,
exitCode: result.exitCode);
}
/// Clones a copy of the flutter repo into the destination directory. Returns false if unsuccessful.
Future<bool> cloneFlutter(String revision, String destination) async {
// Use https url instead of ssh to avoid need to setup ssh on git.
List<String> cmdArgs = <String>[
'git',
'clone',
'--filter=blob:none',
'https://github.com/flutter/flutter.git',
destination
];
ProcessResult result = await _runCommand(cmdArgs);
checkForErrors(result, commandDescription: cmdArgs.join(' '));
cmdArgs.clear();
cmdArgs = <String>['git', 'reset', '--hard', revision];
result = await _runCommand(cmdArgs, workingDirectory: destination);
if (!checkForErrors(result,
commandDescription: cmdArgs.join(' '), exit: false)) {
return false;
}
return true;
}
/// Calls `flutter create` as a re-entrant command.
Future<String> createFromTemplates(
String flutterBinPath, {
required String name,
bool legacyNameParameter = false,
required String androidLanguage,
required String iosLanguage,
required String outputDirectory,
String? createVersion,
List<String> platforms = const <String>[],
int iterationsAllowed = 5,
}) async {
// Limit the number of iterations this command is allowed to attempt to prevent infinite looping.
if (iterationsAllowed <= 0) {
_logger.printError(
'Unable to `flutter create` with the version of flutter at $flutterBinPath');
return outputDirectory;
}
final List<String> cmdArgs = <String>['$flutterBinPath/flutter', 'create'];
if (!legacyNameParameter) {
cmdArgs.add('--project-name=$name');
}
cmdArgs.add('--android-language=$androidLanguage');
cmdArgs.add('--ios-language=$iosLanguage');
if (platforms.isNotEmpty) {
String platformsArg = '--platforms=';
for (int i = 0; i < platforms.length; i++) {
if (i > 0) {
platformsArg += ',';
}
platformsArg += platforms[i];
}
cmdArgs.add(platformsArg);
}
cmdArgs.add('--no-pub');
if (legacyNameParameter) {
cmdArgs.add(name);
} else {
cmdArgs.add(outputDirectory);
}
final ProcessResult result =
await _runCommand(cmdArgs, workingDirectory: outputDirectory);
final String error = result.stderr as String;
// Catch errors due to parameters not existing.
// Old versions of the tool does not include the platforms option.
if (error.contains('Could not find an option named "platforms".')) {
return createFromTemplates(
flutterBinPath,
name: name,
legacyNameParameter: legacyNameParameter,
androidLanguage: androidLanguage,
iosLanguage: iosLanguage,
outputDirectory: outputDirectory,
iterationsAllowed: iterationsAllowed--,
);
}
// Old versions of the tool does not include the project-name option.
if ((result.stderr as String)
.contains('Could not find an option named "project-name".')) {
return createFromTemplates(
flutterBinPath,
name: name,
legacyNameParameter: true,
androidLanguage: androidLanguage,
iosLanguage: iosLanguage,
outputDirectory: outputDirectory,
platforms: platforms,
iterationsAllowed: iterationsAllowed--,
);
}
if (error.contains('Multiple output directories specified.')) {
if (error.contains('Try moving --platforms')) {
return createFromTemplates(
flutterBinPath,
name: name,
legacyNameParameter: legacyNameParameter,
androidLanguage: androidLanguage,
iosLanguage: iosLanguage,
outputDirectory: outputDirectory,
iterationsAllowed: iterationsAllowed--,
);
}
}
checkForErrors(result, commandDescription: cmdArgs.join(' '), silent: true);
if (legacyNameParameter) {
return _fileSystem.path.join(outputDirectory, name);
}
return outputDirectory;
}
/// Runs the git 3-way merge on three files and returns the results as a MergeResult.
///
/// Passing the same path for base and current will perform a two-way fast forward merge.
Future<MergeResult> gitMergeFile({
required String base,
required String current,
required String target,
required String localPath,
}) async {
final List<String> cmdArgs = <String>[
'git',
'merge-file',
'-p',
current,
base,
target
];
final ProcessResult result = await _runCommand(cmdArgs);
checkForErrors(result,
allowedExitCodes: <int>[-1], commandDescription: cmdArgs.join(' '));
return StringMergeResult(result, localPath);
}
/// Calls `git init` on the workingDirectory.
Future<void> gitInit(String workingDirectory) async {
final List<String> cmdArgs = <String>['git', 'init'];
final ProcessResult result =
await _runCommand(cmdArgs, workingDirectory: workingDirectory);
checkForErrors(result, commandDescription: cmdArgs.join(' '));
}
/// Returns true if the workingDirectory git repo has any uncommited changes.
Future<bool> hasUncommittedChanges(String workingDirectory,
{String? migrateStagingDir}) async {
final List<String> cmdArgs = <String>[
'git',
'ls-files',
'--deleted',
'--modified',
'--others',
'--exclude-standard',
'--exclude=${migrateStagingDir ?? kDefaultMigrateStagingDirectoryName}'
];
final ProcessResult result =
await _runCommand(cmdArgs, workingDirectory: workingDirectory);
checkForErrors(result,
allowedExitCodes: <int>[-1], commandDescription: cmdArgs.join(' '));
if ((result.stdout as String).isEmpty) {
return false;
}
return true;
}
/// Returns true if the workingDirectory is a git repo.
Future<bool> isGitRepo(String workingDirectory) async {
final List<String> cmdArgs = <String>[
'git',
'rev-parse',
'--is-inside-work-tree'
];
final ProcessResult result =
await _runCommand(cmdArgs, workingDirectory: workingDirectory);
checkForErrors(result,
allowedExitCodes: <int>[-1], commandDescription: cmdArgs.join(' '));
if (result.exitCode == 0) {
return true;
}
return false;
}
/// Returns true if the file at `filePath` is covered by the `.gitignore`
Future<bool> isGitIgnored(String filePath, String workingDirectory) async {
final List<String> cmdArgs = <String>['git', 'check-ignore', filePath];
final ProcessResult result =
await _runCommand(cmdArgs, workingDirectory: workingDirectory);
checkForErrors(result,
allowedExitCodes: <int>[0, 1, 128],
commandDescription: cmdArgs.join(' '));
return result.exitCode == 0;
}
/// Runs `flutter pub upgrade --major-revisions`.
Future<void> flutterPubUpgrade(String workingDirectory) async {
final List<String> cmdArgs = <String>[
'flutter',
'pub',
'upgrade',
'--major-versions'
];
final ProcessResult result =
await _runCommand(cmdArgs, workingDirectory: workingDirectory);
checkForErrors(result, commandDescription: cmdArgs.join(' '));
}
/// Runs `./gradlew tasks` in the android directory of a flutter project.
Future<void> gradlewTasks(String workingDirectory) async {
final String baseCommand = isWindows ? 'gradlew.bat' : './gradlew';
final List<String> cmdArgs = <String>[baseCommand, 'tasks'];
final ProcessResult result = await _runCommand(cmdArgs,
workingDirectory: workingDirectory, runInShell: isWindows);
checkForErrors(result, commandDescription: cmdArgs.join(' '));
}
/// Verifies that the ProcessResult does not contain an error.
///
/// If an error is detected, the error can be optionally logged or exit the tool.
///
/// Passing -1 in allowedExitCodes means all exit codes are valid.
bool checkForErrors(ProcessResult result,
{List<int> allowedExitCodes = const <int>[0],
String? commandDescription,
bool exit = true,
bool silent = false}) {
if (allowedExitCodes.contains(result.exitCode) ||
allowedExitCodes.contains(-1)) {
return true;
}
if (!silent) {
_logger.printError(
'Command encountered an error with exit code ${result.exitCode}.');
if (commandDescription != null) {
_logger.printError('Command:');
_logger.printError(commandDescription, indent: 2);
}
_logger.printError('Stdout:');
_logger.printError(result.stdout as String, indent: 2);
_logger.printError('Stderr:');
_logger.printError(result.stderr as String, indent: 2);
}
if (exit) {
throwToolExit(
'Command failed with exit code ${result.exitCode}: ${result.stderr}\n${result.stdout}',
exitCode: result.exitCode);
}
return false;
}
/// Returns true if the file does not contain any git conflit markers.
bool conflictsResolved(String contents) {
final bool hasMarker = contents.contains('>>>>>>>') ||
contents.contains('=======') ||
contents.contains('<<<<<<<');
return !hasMarker;
}
}
Future<bool> gitRepoExists(
String projectDirectory, Logger logger, MigrateUtils migrateUtils) async {
if (await migrateUtils.isGitRepo(projectDirectory)) {
return true;
}
logger.printStatus(
'Project is not a git repo. Please initialize a git repo and try again.');
printCommand('git init', logger);
return false;
}
Future<bool> hasUncommittedChanges(
String projectDirectory, Logger logger, MigrateUtils migrateUtils) async {
if (await migrateUtils.hasUncommittedChanges(projectDirectory)) {
logger.printStatus(
'There are uncommitted changes in your project. Please git commit, abandon, or stash your changes before trying again.');
logger.printStatus('You may commit your changes using');
printCommand('git add .', logger, newlineAfter: false);
printCommand('git commit -m "<message>"', logger);
return true;
}
return false;
}
void printCommand(String command, Logger logger, {bool newlineAfter = true}) {
logger.printStatus(
'\n\$ $command${newlineAfter ? '\n' : ''}',
color: TerminalColor.grey,
indent: 4,
newline: false,
);
}
/// Prints a command to logger with appropriate formatting.
void printCommandText(String command, Logger logger,
{bool? standalone = true, bool newlineAfter = true}) {
final String prefix = standalone == null
? ''
: (standalone
? 'dart run <flutter_migrate_dir>${Platform.pathSeparator}bin${Platform.pathSeparator}flutter_migrate.dart '
: 'flutter migrate ');
printCommand('$prefix$command', logger, newlineAfter: newlineAfter);
}
/// Defines the classification of difference between files.
enum DiffType {
command,
addition,
deletion,
ignored,
none,
}
/// Tracks the output of a git diff command or any special cases such as addition of a new
/// file or deletion of an existing file.
class DiffResult {
DiffResult({
required this.diffType,
this.diff,
this.exitCode,
}) : assert(diffType == DiffType.command && exitCode != null ||
diffType != DiffType.command && exitCode == null);
/// The diff string output by git.
final String? diff;
final DiffType diffType;
/// The exit code of the command. This is zero when no diffs are found.
///
/// The exitCode is null when the diffType is not `command`.
final int? exitCode;
}
/// Data class to hold the results of a merge.
abstract class MergeResult {
/// Initializes a MergeResult based off of a ProcessResult.
MergeResult(ProcessResult result, this.localPath)
: hasConflict = result.exitCode != 0,
exitCode = result.exitCode;
/// Manually initializes a MergeResult with explicit values.
MergeResult.explicit({
required this.hasConflict,
required this.exitCode,
required this.localPath,
});
/// True when there is a merge conflict.
bool hasConflict;
/// The exitcode of the merge command.
int exitCode;
/// The local path relative to the project root of the file.
String localPath;
}
/// The results of a string merge.
class StringMergeResult extends MergeResult {
/// Initializes a BinaryMergeResult based off of a ProcessResult.
StringMergeResult(super.result, super.localPath)
: mergedString = result.stdout as String;
/// Manually initializes a StringMergeResult with explicit values.
StringMergeResult.explicit({
required this.mergedString,
required super.hasConflict,
required super.exitCode,
required super.localPath,
}) : super.explicit();
/// The final merged string.
String mergedString;
}
/// The results of a binary merge.
class BinaryMergeResult extends MergeResult {
/// Initializes a BinaryMergeResult based off of a ProcessResult.
BinaryMergeResult(super.result, super.localPath)
: mergedBytes = result.stdout as Uint8List;
/// Manually initializes a BinaryMergeResult with explicit values.
BinaryMergeResult.explicit({
required this.mergedBytes,
required super.hasConflict,
required super.exitCode,
required super.localPath,
}) : super.explicit();
/// The final merged bytes.
Uint8List mergedBytes;
}