blob: 1f37b3d7a7bc871da3d296c4eaba286192cd8572 [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 'package:path/path.dart';
import 'base/common.dart';
import 'base/file_system.dart';
import 'base/logger.dart';
import 'base/project.dart';
import 'custom_merge.dart';
import 'environment.dart';
import 'flutter_project_metadata.dart';
import 'migrate_logger.dart';
import 'result.dart';
import 'utils.dart';
// This defines paths of files and directories relative to the project root
// that should be ignored by the migrate tool regardless of .gitignore and
// config settings.
// Paths use `/` as a stand-in for path separator.
const List<String> _skippedFiles = <String>[
'ios/Runner.xcodeproj/project.pbxproj', // Xcode managed configs that may not merge cleanly.
'README.md', // changes to this shouldn't be overwritten since is is user owned.
];
const List<String> _skippedDirectories = <String>[
'.dart_tool', // The .dart_tool generated dir.
'.git', // Git metadata.
'assets', // Common directory for user assets.
'build', // Build artifacts.
'lib', // Always user owned and we don't want to overwrite their apps.
'test', // Typically user owned and flutter-side changes are not relevant.
];
final Iterable<String> canonicalizedSkippedFiles = _skippedFiles.map<String>(
(String path) => canonicalize(path),
);
// Returns true for paths relative to the project root that should be skipped
// completely by the migrate tool.
bool _skipped(String localPath, FileSystem fileSystem,
{Set<String>? skippedPrefixes}) {
final String canonicalizedLocalPath = canonicalize(localPath);
final Iterable<String> canonicalizedSkippedFiles =
_skippedFiles.map<String>((String path) => canonicalize(path));
if (canonicalizedSkippedFiles.contains(canonicalizedLocalPath)) {
return true;
}
final Iterable<String> canonicalizedSkippedDirectories =
_skippedDirectories.map<String>((String path) => canonicalize(path));
for (final String dir in canonicalizedSkippedDirectories) {
if (canonicalizedLocalPath.startsWith('$dir${fileSystem.path.separator}')) {
return true;
}
}
if (skippedPrefixes != null) {
return skippedPrefixes.any((String prefix) => localPath.startsWith(
'${normalize(prefix.replaceAll(r'\', fileSystem.path.separator))}${fileSystem.path.separator}'));
}
return false;
}
// File extensions that the tool will not attempt to merge. Changes
// in files with these extensions will be accepted wholesale.
//
// The executables and binaries in this list are not meant to be
// comprehensive and need only cover the files that are generated
// in `flutter create` as only files generated by the template
// will be attempted to be merged.
const List<String> _doNotMergeFileExtensions = <String>[
// Don't merge image files
'.bmp',
'.gif',
'.jpg',
'.jpeg',
'.png',
'.svg',
// Don't merge compiled artifacts and executables
'.dll',
'.exe',
'.jar',
'.so',
];
// These files should always go through the migrate process as
// they are either integral to the migrate process or we expect
// new versions of this file to always be desired.
const Set<String> _alwaysMigrateFiles = <String>{
'.metadata', // .metadata tracks key migration information.
'android/gradle/wrapper/gradle-wrapper.jar',
// Always add .gitignore back in even if user-deleted as it makes it
// difficult to migrate in the future and the migrate tool enforces git
// usage.
'.gitignore',
};
/// False for files that should not be merged. Typically, images and binary files.
bool _mergable(String localPath) {
return _alwaysMigrateFiles.contains(localPath) ||
!_doNotMergeFileExtensions.any((String ext) => localPath.endsWith(ext));
}
// Compile the set of path prefixes that should be ignored as configured
// in the command arguments.
Set<String> _getSkippedPrefixes(List<SupportedPlatform> platforms) {
final Set<String> skippedPrefixes = <String>{};
for (final SupportedPlatform platform in SupportedPlatform.values) {
skippedPrefixes.add(platformToSubdirectoryPrefix(platform));
}
for (final SupportedPlatform platform in platforms) {
skippedPrefixes.remove(platformToSubdirectoryPrefix(platform));
}
return skippedPrefixes;
}
/// Data class holds the common context that is used throughout the steps of a migrate computation.
class MigrateContext {
MigrateContext({
required this.flutterProject,
required this.skippedPrefixes,
required this.fileSystem,
required this.migrateLogger,
required this.migrateUtils,
required this.environment,
this.baseProject,
this.targetProject,
});
final FlutterProject flutterProject;
final Set<String> skippedPrefixes;
final FileSystem fileSystem;
final MigrateLogger migrateLogger;
final MigrateUtils migrateUtils;
final FlutterToolsEnvironment environment;
MigrateBaseFlutterProject? baseProject;
MigrateTargetFlutterProject? targetProject;
}
/// Returns the path relative to the flutter project's root.
String getLocalPath(String path, String basePath, FileSystem fileSystem) {
return path.replaceFirst(basePath + fileSystem.path.separator, '');
}
String platformToSubdirectoryPrefix(SupportedPlatform platform) {
switch (platform) {
case SupportedPlatform.android:
return 'android';
case SupportedPlatform.ios:
return 'ios';
case SupportedPlatform.linux:
return 'linux';
case SupportedPlatform.macos:
return 'macos';
case SupportedPlatform.web:
return 'web';
case SupportedPlatform.windows:
return 'windows';
case SupportedPlatform.fuchsia:
return 'fuchsia';
}
}
/// Data class that contains the command line arguments passed by the user.
class MigrateCommandParameters {
MigrateCommandParameters({
this.baseAppPath,
this.targetAppPath,
this.baseRevision,
this.targetRevision,
this.preferTwoWayMerge = false,
this.verbose = false,
this.allowFallbackBaseRevision = false,
this.deleteTempDirectories = true,
this.platforms,
});
final String? baseAppPath;
final String? targetAppPath;
final String? baseRevision;
final String? targetRevision;
final bool preferTwoWayMerge;
final bool verbose;
final bool allowFallbackBaseRevision;
final bool deleteTempDirectories;
final List<SupportedPlatform>? platforms;
}
/// Computes the changes that migrates the current flutter project to the target revision.
///
/// This is the entry point to the core migration computations and drives the migration process.
///
/// This method attempts to find a base revision, which is the revision of the Flutter SDK
/// the app was generated with or the last revision the app was migrated to. The base revision
/// typically comes from the .metadata, but for legacy apps, the config may not exist. In
/// this case, we fallback to using the revision in .metadata, and if that does not exist, we
/// use the target revision as the base revision. In the final fallback case, the migration should
/// still work, but will likely generate slightly less accurate merges.
///
/// Operations the computation performs:
///
/// - Parse .metadata file
/// - Collect revisions to use for each platform
/// - Download each flutter revision and call `flutter create` for each.
/// - Call `flutter create` with target revision (target is typically current flutter version)
/// - Diff base revision generated app with target revision generated app
/// - Compute all newly added files between base and target revisions
/// - Compute merge of all files that are modified by user and flutter
/// - Track temp dirs to be deleted
///
/// Structure: This method builds upon a MigrateResult instance
Future<MigrateResult?> computeMigration({
FlutterProject? flutterProject,
required MigrateCommandParameters commandParameters,
required FileSystem fileSystem,
required Logger logger,
required MigrateUtils migrateUtils,
required FlutterToolsEnvironment environment,
}) async {
flutterProject ??= FlutterProject.current(fileSystem);
final MigrateLogger migrateLogger =
MigrateLogger(logger: logger, verbose: commandParameters.verbose);
migrateLogger.logStep('start');
// Find the path prefixes to ignore. This allows subdirectories of platforms
// not part of the migration to be skipped.
final List<SupportedPlatform> platforms =
commandParameters.platforms ?? flutterProject.getSupportedPlatforms();
final Set<String> skippedPrefixes = _getSkippedPrefixes(platforms);
final MigrateResult result = MigrateResult.empty();
final MigrateContext context = MigrateContext(
flutterProject: flutterProject,
skippedPrefixes: skippedPrefixes,
migrateLogger: migrateLogger,
fileSystem: fileSystem,
migrateUtils: migrateUtils,
environment: environment,
);
migrateLogger.logStep('revisions');
final MigrateRevisions revisionConfig = MigrateRevisions(
context: context,
baseRevision: commandParameters.baseRevision,
allowFallbackBaseRevision: commandParameters.allowFallbackBaseRevision,
platforms: platforms,
environment: environment,
);
// Extract the unamanged files/paths that should be ignored by the migrate tool.
// These paths are absolute paths.
migrateLogger.logStep('unmanaged');
final List<String> unmanagedFiles = <String>[];
final List<String> unmanagedDirectories = <String>[];
final String basePath = flutterProject.directory.path;
for (final String localPath in revisionConfig.config.unmanagedFiles) {
if (localPath.endsWith(fileSystem.path.separator)) {
unmanagedDirectories.add(fileSystem.path.join(basePath, localPath));
} else {
unmanagedFiles.add(fileSystem.path.join(basePath, localPath));
}
}
migrateLogger.logStep('generating_base');
// Generate the base templates
final ReferenceProjects referenceProjects =
await _generateBaseAndTargetReferenceProjects(
context: context,
result: result,
revisionConfig: revisionConfig,
platforms: platforms,
commandParameters: commandParameters,
);
// Generate diffs. These diffs are used to determine if a file is newly added, needs merging,
// or deleted (rare). Only files with diffs between the base and target revisions need to be
// migrated. If files are unchanged between base and target, then there are no changes to merge.
migrateLogger.logStep('diff');
result.diffMap.addAll(await referenceProjects.baseProject
.diff(context, referenceProjects.targetProject));
// Check for any new files that were added in the target reference app that did not
// exist in the base reference app.
migrateLogger.logStep('new_files');
result.addedFiles.addAll(await referenceProjects.baseProject
.computeNewlyAddedFiles(
context, result, referenceProjects.targetProject));
// Merge any base->target changed files with the version in the developer's project.
// Files that the developer left unchanged are fully updated to match the target reference.
// Files that the developer changed and were changed from base->target are merged.
migrateLogger.logStep('merging');
await MigrateFlutterProject.merge(
context,
result,
referenceProjects.baseProject,
referenceProjects.targetProject,
unmanagedFiles,
unmanagedDirectories,
commandParameters.preferTwoWayMerge,
);
// Clean up any temp directories generated by this tool.
migrateLogger.logStep('cleaning');
_registerTempDirectoriesForCleaning(
commandParameters: commandParameters,
result: result,
referenceProjects: referenceProjects);
migrateLogger.stop();
return result;
}
/// Returns a base revision to fallback to in case a true base revision is unknown.
String _getFallbackBaseRevision(
bool allowFallbackBaseRevision, MigrateLogger migrateLogger) {
if (!allowFallbackBaseRevision) {
migrateLogger.stop();
migrateLogger.printError(
'Could not determine base revision this app was created with:');
migrateLogger.printError(
'.metadata file did not exist or did not contain a valid revision.',
indent: 2);
migrateLogger.printError(
'Run this command again with the `--allow-fallback-base-revision` flag to use Flutter v1.0.0 as the base revision or manually pass a revision with `--base-revision=<revision>`',
indent: 2);
throwToolExit('Failed to resolve base revision');
}
// Earliest version of flutter with .metadata: c17099f474675d8066fec6984c242d8b409ae985 (2017)
// Flutter 2.0.0: 60bd88df915880d23877bfc1602e8ddcf4c4dd2a
// Flutter v1.0.0: 5391447fae6209bb21a89e6a5a6583cac1af9b4b
//
// TODO(garyq): Use things like dart sdk version and other hints to better fine-tune this fallback.
//
// We fall back on flutter v1.0.0 if .metadata doesn't exist.
migrateLogger.printIfVerbose(
'Could not determine base revision, falling back on `v1.0.0`, revision 5391447fae6209bb21a89e6a5a6583cac1af9b4b');
return '5391447fae6209bb21a89e6a5a6583cac1af9b4b';
}
/// Simple data class that holds the base and target reference
/// projects.
class ReferenceProjects {
ReferenceProjects({
required this.baseProject,
required this.targetProject,
required this.customBaseProjectDir,
required this.customTargetProjectDir,
});
MigrateBaseFlutterProject baseProject;
MigrateTargetFlutterProject targetProject;
// Whether a user provided base and target projects were provided.
bool customBaseProjectDir;
bool customTargetProjectDir;
}
// Generate reference base and target flutter projects.
//
// This function generates reference vaniilla projects by using `flutter create` with
// the base revision Flutter SDK as well as the target revision SDK.
Future<ReferenceProjects> _generateBaseAndTargetReferenceProjects({
required MigrateContext context,
required MigrateResult result,
required MigrateRevisions revisionConfig,
required List<SupportedPlatform> platforms,
required MigrateCommandParameters commandParameters,
}) async {
// Use user-provided projects if provided, if not, generate them internally.
final bool customBaseProjectDir = commandParameters.baseAppPath != null;
final bool customTargetProjectDir = commandParameters.targetAppPath != null;
Directory baseProjectDir =
context.fileSystem.systemTempDirectory.createTempSync('baseProject');
Directory targetProjectDir =
context.fileSystem.systemTempDirectory.createTempSync('targetProject');
if (customBaseProjectDir) {
baseProjectDir =
context.fileSystem.directory(commandParameters.baseAppPath);
} else {
baseProjectDir =
context.fileSystem.systemTempDirectory.createTempSync('baseProject');
context.migrateLogger
.printIfVerbose('Created temporary directory: ${baseProjectDir.path}');
}
if (customTargetProjectDir) {
targetProjectDir =
context.fileSystem.directory(commandParameters.targetAppPath);
} else {
targetProjectDir =
context.fileSystem.systemTempDirectory.createTempSync('targetProject');
context.migrateLogger.printIfVerbose(
'Created temporary directory: ${targetProjectDir.path}');
}
// Git init to enable running further git commands on the reference projects.
await context.migrateUtils.gitInit(baseProjectDir.absolute.path);
await context.migrateUtils.gitInit(targetProjectDir.absolute.path);
result.generatedBaseTemplateDirectory = baseProjectDir;
result.generatedTargetTemplateDirectory = targetProjectDir;
final String name =
context.environment['FlutterProject.manifest.appname']! as String;
final String androidLanguage =
context.environment['FlutterProject.android.isKotlin']! as bool
? 'kotlin'
: 'java';
final String iosLanguage =
context.environment['FlutterProject.ios.isSwift']! as bool
? 'swift'
: 'objc';
final Directory targetFlutterDirectory = context.fileSystem
.directory(context.environment.getString('Cache.flutterRoot'));
// Create the base reference vanilla app.
//
// This step clones the base flutter sdk, and uses it to create a new vanilla app.
// The vanilla base app is used as part of a 3 way merge between the base app, target
// app, and the current user-owned app.
final MigrateBaseFlutterProject baseProject = MigrateBaseFlutterProject(
path: commandParameters.baseAppPath,
directory: baseProjectDir,
name: name,
androidLanguage: androidLanguage,
iosLanguage: iosLanguage,
platformWhitelist: platforms,
);
context.baseProject = baseProject;
await baseProject.createProject(
context,
result,
revisionConfig.revisionsList,
revisionConfig.revisionToConfigs,
commandParameters.baseRevision ??
revisionConfig.metadataRevision ??
_getFallbackBaseRevision(
commandParameters.allowFallbackBaseRevision, context.migrateLogger),
revisionConfig.targetRevision,
targetFlutterDirectory,
);
// Create target reference app when not provided.
//
// This step directly calls flutter create with the target (the current installed revision)
// flutter sdk.
final MigrateTargetFlutterProject targetProject = MigrateTargetFlutterProject(
path: commandParameters.targetAppPath,
directory: targetProjectDir,
name: name,
androidLanguage: androidLanguage,
iosLanguage: iosLanguage,
platformWhitelist: platforms,
);
context.targetProject = targetProject;
await targetProject.createProject(
context,
result,
revisionConfig.targetRevision,
targetFlutterDirectory,
);
return ReferenceProjects(
baseProject: baseProject,
targetProject: targetProject,
customBaseProjectDir: customBaseProjectDir,
customTargetProjectDir: customTargetProjectDir,
);
}
// Registers any generated temporary directories for optional deletion upon tool exit.
void _registerTempDirectoriesForCleaning({
required MigrateCommandParameters commandParameters,
required MigrateResult result,
required ReferenceProjects referenceProjects,
}) {
if (commandParameters.deleteTempDirectories) {
// Don't delete user-provided directories
if (!referenceProjects.customBaseProjectDir) {
result.tempDirectories.add(result.generatedBaseTemplateDirectory!);
}
if (!referenceProjects.customTargetProjectDir) {
result.tempDirectories.add(result.generatedTargetTemplateDirectory!);
}
result.tempDirectories.addAll(result.sdkDirs.values);
}
}
/// A reference flutter project.
///
/// A MigrateFlutterProject is a project that is generated internally within the tool
/// to see what changes need to be made to the user's project. This class
/// provides methods to merge, diff, and otherwise compare multiple MigrateFlutterProject
/// instances.
abstract class MigrateFlutterProject {
MigrateFlutterProject({
required this.path,
required this.directory,
required this.name,
required this.androidLanguage,
required this.iosLanguage,
this.platformWhitelist,
});
final String? path;
final Directory directory;
final String name;
final String androidLanguage;
final String iosLanguage;
final List<SupportedPlatform>? platformWhitelist;
/// Run git diff over each matching pair of files in the this project and the provided target project.
Future<Map<String, DiffResult>> diff(
MigrateContext context,
MigrateFlutterProject other,
) async {
final Map<String, DiffResult> diffMap = <String, DiffResult>{};
final List<FileSystemEntity> thisFiles =
directory.listSync(recursive: true);
int modifiedFilesCount = 0;
for (final FileSystemEntity entity in thisFiles) {
if (entity is! File) {
continue;
}
final File thisFile = entity.absolute;
final String localPath = getLocalPath(
thisFile.path, directory.absolute.path, context.fileSystem);
if (_skipped(localPath, context.fileSystem,
skippedPrefixes: context.skippedPrefixes)) {
continue;
}
if (await context.migrateUtils
.isGitIgnored(thisFile.absolute.path, directory.absolute.path)) {
diffMap[localPath] = DiffResult(diffType: DiffType.ignored);
}
final File otherFile = other.directory.childFile(localPath);
if (otherFile.existsSync()) {
final DiffResult diff =
await context.migrateUtils.diffFiles(thisFile, otherFile);
diffMap[localPath] = diff;
if (diff.diff != '') {
context.migrateLogger.printIfVerbose(
'Found ${diff.exitCode} changes in $localPath',
indent: 4);
modifiedFilesCount++;
}
} else {
// Current file has no new template counterpart, which is equivalent to a deletion.
// This could also indicate a renaming if there is an addition with equivalent contents.
diffMap[localPath] = DiffResult(diffType: DiffType.deletion);
}
}
context.migrateLogger.printIfVerbose(
'$modifiedFilesCount files were modified between base and target apps.');
return diffMap;
}
/// Find all files that exist in the target reference app but not in the base reference app.
Future<List<FilePendingMigration>> computeNewlyAddedFiles(
MigrateContext context,
MigrateResult result,
MigrateFlutterProject other,
) async {
final List<FilePendingMigration> addedFiles = <FilePendingMigration>[];
final List<FileSystemEntity> otherFiles =
other.directory.listSync(recursive: true);
for (final FileSystemEntity entity in otherFiles) {
if (entity is! File) {
continue;
}
final File otherFile = entity.absolute;
final String localPath = getLocalPath(
otherFile.path, other.directory.absolute.path, context.fileSystem);
if (directory.childFile(localPath).existsSync() ||
_skipped(localPath, context.fileSystem,
skippedPrefixes: context.skippedPrefixes)) {
continue;
}
if (await context.migrateUtils.isGitIgnored(
otherFile.absolute.path, other.directory.absolute.path)) {
result.diffMap[localPath] = DiffResult(diffType: DiffType.ignored);
}
result.diffMap[localPath] = DiffResult(diffType: DiffType.addition);
if (context.flutterProject.directory.childFile(localPath).existsSync()) {
// Don't store as added file if file already exists in the project.
continue;
}
addedFiles.add(FilePendingMigration(localPath, otherFile));
}
context.migrateLogger.printIfVerbose(
'${addedFiles.length} files were newly added in the target app.');
return addedFiles;
}
/// Loops through each existing file and intelligently merges it with the base->target changes.
static Future<void> merge(
MigrateContext context,
MigrateResult result,
MigrateFlutterProject baseProject,
MigrateFlutterProject targetProject,
List<String> unmanagedFiles,
List<String> unmanagedDirectories,
bool preferTwoWayMerge,
) async {
final List<CustomMerge> customMerges = <CustomMerge>[
MetadataCustomMerge(logger: context.migrateLogger.logger),
];
// For each existing file in the project, we attempt to 3 way merge if it is changed by the user.
final List<FileSystemEntity> currentFiles =
context.flutterProject.directory.listSync(recursive: true);
final String projectRootPath =
context.flutterProject.directory.absolute.path;
final Set<String> missingAlwaysMigrateFiles =
Set<String>.of(_alwaysMigrateFiles);
for (final FileSystemEntity entity in currentFiles) {
if (entity is! File) {
continue;
}
// check if the file is unmanaged/ignored by the migration tool.
bool ignored = false;
ignored = unmanagedFiles.contains(entity.absolute.path);
for (final String path in unmanagedDirectories) {
if (entity.absolute.path.startsWith(path)) {
ignored = true;
break;
}
}
if (ignored) {
continue; // Skip if marked as unmanaged
}
final File currentFile = entity.absolute;
// Diff the current file against the old generated template
final String localPath =
getLocalPath(currentFile.path, projectRootPath, context.fileSystem);
missingAlwaysMigrateFiles.remove(localPath);
if (result.diffMap.containsKey(localPath) &&
result.diffMap[localPath]!.diffType == DiffType.ignored ||
await context.migrateUtils.isGitIgnored(currentFile.path,
context.flutterProject.directory.absolute.path) ||
_skipped(localPath, context.fileSystem,
skippedPrefixes: context.skippedPrefixes) ||
!_mergable(localPath)) {
continue;
}
final File baseTemplateFile = baseProject.directory.childFile(localPath);
final File targetTemplateFile =
targetProject.directory.childFile(localPath);
final DiffResult userDiff =
await context.migrateUtils.diffFiles(currentFile, baseTemplateFile);
final DiffResult targetDiff =
await context.migrateUtils.diffFiles(currentFile, targetTemplateFile);
if (targetDiff.exitCode == 0) {
// current file is already the same as the target file.
continue;
}
final bool alwaysMigrate = _alwaysMigrateFiles.contains(localPath);
// Current file unchanged by user, thus we consider it owned by the tool.
if (userDiff.exitCode == 0 || alwaysMigrate) {
if ((result.diffMap.containsKey(localPath) || alwaysMigrate) &&
result.diffMap[localPath] != null) {
// File changed between base and target
if (result.diffMap[localPath]!.diffType == DiffType.deletion) {
// File is deleted in new template
result.deletedFiles
.add(FilePendingMigration(localPath, currentFile));
continue;
}
if (result.diffMap[localPath]!.exitCode != 0 || alwaysMigrate) {
// Accept the target version wholesale
MergeResult mergeResult;
try {
mergeResult = StringMergeResult.explicit(
mergedString: targetTemplateFile.readAsStringSync(),
hasConflict: false,
exitCode: 0,
localPath: localPath,
);
} on FileSystemException {
mergeResult = BinaryMergeResult.explicit(
mergedBytes: targetTemplateFile.readAsBytesSync(),
hasConflict: false,
exitCode: 0,
localPath: localPath,
);
}
result.mergeResults.add(mergeResult);
continue;
}
}
continue;
}
// File changed by user
if (result.diffMap.containsKey(localPath)) {
MergeResult? mergeResult;
// Default to two way merge as it does not require the base file to exist.
MergeType mergeType =
result.mergeTypeMap[localPath] ?? MergeType.twoWay;
for (final CustomMerge customMerge in customMerges) {
if (customMerge.localPath == localPath) {
mergeResult = customMerge.merge(
currentFile, baseTemplateFile, targetTemplateFile);
mergeType = MergeType.custom;
break;
}
}
if (mergeResult == null) {
late String basePath;
late String currentPath;
late String targetPath;
// Use two way merge if diff between base and target are the same.
// This prevents the three way merge re-deleting the base->target changes.
if (preferTwoWayMerge) {
mergeType = MergeType.twoWay;
}
switch (mergeType) {
case MergeType.twoWay:
{
basePath = currentFile.path;
currentPath = currentFile.path;
targetPath = context.fileSystem.path.join(
result.generatedTargetTemplateDirectory!.path, localPath);
break;
}
case MergeType.threeWay:
{
basePath = context.fileSystem.path.join(
result.generatedBaseTemplateDirectory!.path, localPath);
currentPath = currentFile.path;
targetPath = context.fileSystem.path.join(
result.generatedTargetTemplateDirectory!.path, localPath);
break;
}
case MergeType.custom:
{
break; // handled above
}
}
if (mergeType != MergeType.custom) {
mergeResult = await context.migrateUtils.gitMergeFile(
base: basePath,
current: currentPath,
target: targetPath,
localPath: localPath,
);
}
}
if (mergeResult != null) {
// Don't include if result is identical to the current file.
if (mergeResult is StringMergeResult) {
if (mergeResult.mergedString == currentFile.readAsStringSync()) {
context.migrateLogger
.printIfVerbose('$localPath was merged with a $mergeType.');
continue;
}
} else {
if ((mergeResult as BinaryMergeResult).mergedBytes ==
currentFile.readAsBytesSync()) {
continue;
}
}
result.mergeResults.add(mergeResult);
}
context.migrateLogger
.printStatus('$localPath was merged with a $mergeType.');
continue;
}
}
// Add files that are in the target, marked as always migrate, and missing in the current project.
for (final String localPath in missingAlwaysMigrateFiles) {
final File targetTemplateFile =
result.generatedTargetTemplateDirectory!.childFile(localPath);
if (targetTemplateFile.existsSync() &&
!_skipped(localPath, context.fileSystem,
skippedPrefixes: context.skippedPrefixes)) {
result.addedFiles
.add(FilePendingMigration(localPath, targetTemplateFile));
}
}
}
}
/// The base reference project used in a migration computation.
///
/// This project is a clean re-generation of the version the user's project
/// was 1. originally generated with, or 2. the last successful migrated to.
class MigrateBaseFlutterProject extends MigrateFlutterProject {
MigrateBaseFlutterProject({
required super.path,
required super.directory,
required super.name,
required super.androidLanguage,
required super.iosLanguage,
super.platformWhitelist,
});
/// Creates the base reference app based off of the migrate config in the .metadata file.
Future<void> createProject(
MigrateContext context,
MigrateResult result,
List<String> revisionsList,
Map<String, List<MigratePlatformConfig>> revisionToConfigs,
String fallbackRevision,
String targetRevision,
Directory targetFlutterDirectory,
) async {
// Create base
// Clone base flutter
if (path == null) {
final Map<String, Directory> revisionToFlutterSdkDir =
<String, Directory>{};
for (final String revision in revisionsList) {
final List<String> platforms = <String>[];
for (final MigratePlatformConfig config
in revisionToConfigs[revision]!) {
if (config.component == FlutterProjectComponent.root) {
continue;
}
platforms.add(config.component.toString().split('.').last);
}
// In the case of the revision being invalid or not a hash of the master branch,
// we want to fallback in the following order:
// - parsed revision
// - fallback revision
// - target revision (currently installed flutter)
late Directory sdkDir;
final List<String> revisionsToTry = <String>[revision];
if (revision != fallbackRevision) {
revisionsToTry.add(fallbackRevision);
}
bool sdkAvailable = false;
int index = 0;
do {
if (index < revisionsToTry.length) {
final String activeRevision = revisionsToTry[index++];
if (activeRevision != revision &&
revisionToFlutterSdkDir.containsKey(activeRevision)) {
sdkDir = revisionToFlutterSdkDir[activeRevision]!;
revisionToFlutterSdkDir[revision] = sdkDir;
sdkAvailable = true;
} else {
sdkDir = context.fileSystem.systemTempDirectory
.createTempSync('flutter_$activeRevision');
result.sdkDirs[activeRevision] = sdkDir;
context.migrateLogger.printStatus('Cloning SDK $activeRevision');
sdkAvailable = await context.migrateUtils
.cloneFlutter(activeRevision, sdkDir.absolute.path);
revisionToFlutterSdkDir[revision] = sdkDir;
}
} else {
// fallback to just using the modern target version of flutter.
sdkDir = targetFlutterDirectory;
revisionToFlutterSdkDir[revision] = sdkDir;
sdkAvailable = true;
}
} while (!sdkAvailable);
context.migrateLogger.printStatus(
'Creating base app for $platforms with revision $revision.');
final String newDirectoryPath =
await context.migrateUtils.createFromTemplates(
sdkDir.childDirectory('bin').absolute.path,
name: name,
androidLanguage: androidLanguage,
iosLanguage: iosLanguage,
outputDirectory: result.generatedBaseTemplateDirectory!.absolute.path,
platforms: platforms,
);
if (newDirectoryPath != result.generatedBaseTemplateDirectory?.path) {
result.generatedBaseTemplateDirectory =
context.fileSystem.directory(newDirectoryPath);
}
// Determine merge type for each newly generated file.
final List<FileSystemEntity> generatedBaseFiles =
result.generatedBaseTemplateDirectory!.listSync(recursive: true);
for (final FileSystemEntity entity in generatedBaseFiles) {
if (entity is! File) {
continue;
}
final File baseTemplateFile = entity.absolute;
final String localPath = getLocalPath(
baseTemplateFile.path,
result.generatedBaseTemplateDirectory!.absolute.path,
context.fileSystem);
if (!result.mergeTypeMap.containsKey(localPath)) {
// Use two way merge when the base revision is the same as the target revision.
result.mergeTypeMap[localPath] = revision == targetRevision
? MergeType.twoWay
: MergeType.threeWay;
}
}
if (newDirectoryPath != result.generatedBaseTemplateDirectory?.path) {
result.generatedBaseTemplateDirectory =
context.fileSystem.directory(newDirectoryPath);
break; // The create command is old and does not distinguish between platforms so it only needs to be called once.
}
}
}
}
}
/// Represents a manifested flutter project that is the migration target.
///
/// The files in this project are the version the migrate tool will try
/// to transform the existing files into.
class MigrateTargetFlutterProject extends MigrateFlutterProject {
MigrateTargetFlutterProject({
required super.path,
required super.directory,
required super.name,
required super.androidLanguage,
required super.iosLanguage,
super.platformWhitelist,
});
/// Creates the base reference app based off of the migrate config in the .metadata file.
Future<void> createProject(
MigrateContext context,
MigrateResult result,
String targetRevision,
Directory targetFlutterDirectory,
) async {
if (path == null) {
// Create target
context.migrateLogger
.printStatus('Creating target app with revision $targetRevision.');
context.migrateLogger.printIfVerbose('Creating target app.');
await context.migrateUtils.createFromTemplates(
targetFlutterDirectory.childDirectory('bin').absolute.path,
name: name,
androidLanguage: androidLanguage,
iosLanguage: iosLanguage,
outputDirectory: result.generatedTargetTemplateDirectory!.absolute.path,
);
}
}
}
/// Parses the metadata of the flutter project, extracts, computes, and stores the
/// revisions that the migration should use to migrate between.
class MigrateRevisions {
MigrateRevisions({
required MigrateContext context,
required String? baseRevision,
required bool allowFallbackBaseRevision,
required List<SupportedPlatform> platforms,
required FlutterToolsEnvironment environment,
}) {
_computeRevisions(context, baseRevision, allowFallbackBaseRevision,
platforms, environment);
}
late List<String> revisionsList;
late Map<String, List<MigratePlatformConfig>> revisionToConfigs;
late String fallbackRevision;
late String targetRevision;
late String? metadataRevision;
late MigrateConfig config;
void _computeRevisions(
MigrateContext context,
String? baseRevision,
bool allowFallbackBaseRevision,
List<SupportedPlatform> platforms,
FlutterToolsEnvironment environment,
) {
final List<FlutterProjectComponent> components =
<FlutterProjectComponent>[];
for (final SupportedPlatform platform in platforms) {
components.add(platform.toFlutterProjectComponent());
}
components.add(FlutterProjectComponent.root);
final FlutterProjectMetadata metadata = FlutterProjectMetadata(
context.flutterProject.directory.childFile('.metadata'),
context.migrateLogger.logger);
config = metadata.migrateConfig;
// We call populate in case MigrateConfig is empty. If it is filled, populate should not do anything.
config.populate(
projectDirectory: context.flutterProject.directory,
update: false,
logger: context.migrateLogger.logger,
);
metadataRevision = metadata.versionRevision;
if (environment.getString('FlutterVersion.frameworkRevision') == null) {
throwToolExit('Flutter framework revision was null');
}
targetRevision = environment.getString('FlutterVersion.frameworkRevision')!;
String rootBaseRevision = '';
revisionToConfigs = <String, List<MigratePlatformConfig>>{};
final Set<String> revisions = <String>{};
if (baseRevision == null) {
for (final MigratePlatformConfig platform
in config.platformConfigs.values) {
final String effectiveRevision = platform.baseRevision == null
? metadataRevision ??
_getFallbackBaseRevision(
allowFallbackBaseRevision, context.migrateLogger)
: platform.baseRevision!;
if (!components.contains(platform.component)) {
continue;
}
if (platform.component == FlutterProjectComponent.root) {
rootBaseRevision = effectiveRevision;
}
revisions.add(effectiveRevision);
if (revisionToConfigs[effectiveRevision] == null) {
revisionToConfigs[effectiveRevision] = <MigratePlatformConfig>[];
}
revisionToConfigs[effectiveRevision]!.add(platform);
}
} else {
rootBaseRevision = baseRevision;
revisionToConfigs[baseRevision] = <MigratePlatformConfig>[];
for (final FlutterProjectComponent component in components) {
revisionToConfigs[baseRevision]!.add(MigratePlatformConfig(
component: component, baseRevision: baseRevision));
}
// revisionToConfigs[baseRevision]!.add(
// MigratePlatformConfig(platform: null, baseRevision: baseRevision));
}
// Reorder such that the root revision is created first.
revisions.remove(rootBaseRevision);
revisionsList = List<String>.from(revisions);
if (rootBaseRevision != '') {
revisionsList.insert(0, rootBaseRevision);
}
context.migrateLogger
.printIfVerbose('Potential base revisions: $revisionsList');
fallbackRevision = _getFallbackBaseRevision(true, context.migrateLogger);
if (revisionsList.contains(fallbackRevision) &&
baseRevision != fallbackRevision &&
metadataRevision != fallbackRevision) {
context.migrateLogger.printStatus(
'Using Flutter v1.0.0 ($fallbackRevision) as the base revision since a valid base revision could not be found in the .metadata file. This may result in more merge conflicts than normally expected.',
indent: 4);
}
}
}