| // 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); |
| } |
| } |
| } |