diff --git a/packages/flutter_tools/lib/src/commands/migrate.dart b/packages/flutter_tools/lib/src/commands/migrate.dart new file mode 100644 index 00000000000..abe7e78003b --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/migrate.dart @@ -0,0 +1,84 @@ +// 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 'package:process/process.dart'; + +// import '../base/file_system.dart'; +import '../base/logger.dart'; +// import '../base/platform.dart'; +import '../base/terminal.dart'; +import '../migrate/migrate_utils.dart'; +import '../runner/flutter_command.dart'; +// TODO(garyq): Add each of these back in as they land. +// import 'migrate_abandon.dart'; +// import 'migrate_apply.dart'; +// import 'migrate_resolve_conflicts.dart'; +// import 'migrate_start.dart'; +// import 'migrate_status.dart'; + +/// Base command for the migration tool. +class MigrateCommand extends FlutterCommand { + MigrateCommand({ + // bool verbose = false, + required this.logger, + // TODO(garyq): Add each of these back in as they land. + // required FileSystem fileSystem, + // required Terminal terminal, + // required Platform platform, + // required ProcessManager processManager, + }) { + // TODO(garyq): Add each of these back in as they land. + // addSubcommand(MigrateAbandonCommand(logger: logger, fileSystem: fileSystem, terminal: terminal, platform: platform, processManager: processManager)); + // addSubcommand(MigrateApplyCommand(verbose: verbose, logger: logger, fileSystem: fileSystem, terminal: terminal, platform: platform, processManager: processManager)); + // addSubcommand(MigrateResolveConflictsCommand(logger: logger, fileSystem: fileSystem, terminal: terminal)); + // addSubcommand(MigrateStartCommand(verbose: verbose, logger: logger, fileSystem: fileSystem, platform: platform, processManager: processManager)); + // addSubcommand(MigrateStatusCommand(verbose: verbose, logger: logger, fileSystem: fileSystem, platform: platform, processManager: processManager)); + } + + final Logger logger; + + @override + final String name = 'migrate'; + + @override + final String description = 'Migrates flutter generated project files to the current flutter version'; + + @override + String get category => FlutterCommandCategory.project; + + @override + Future> get requiredArtifacts async => const {}; + + @override + Future runCommand() async { + return const FlutterCommandResult(ExitStatus.fail); + } +} + +Future 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.'); + printCommandText('git init', logger); + return false; +} + +Future 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.'); + return true; + } + return false; +} + +/// Prints a command to logger with appropriate formatting. +void printCommandText(String command, Logger logger) { + logger.printStatus( + '\n\$ $command\n', + color: TerminalColor.grey, + indent: 4, + newline: false, + ); +} diff --git a/packages/flutter_tools/lib/src/migrate/migrate_manifest.dart b/packages/flutter_tools/lib/src/migrate/migrate_manifest.dart new file mode 100644 index 00000000000..bc168e941fd --- /dev/null +++ b/packages/flutter_tools/lib/src/migrate/migrate_manifest.dart @@ -0,0 +1,241 @@ +// 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 'package:yaml/yaml.dart'; + +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/terminal.dart'; +import 'migrate_result.dart'; +import 'migrate_utils.dart'; + +const String _kMergedFilesKey = 'merged_files'; +const String _kConflictFilesKey = 'conflict_files'; +const String _kAddedFilesKey = 'added_files'; +const String _kDeletedFilesKey = 'deleted_files'; + +/// Represents the manifest file that tracks the contents of the current +/// migration working directory. +/// +/// This manifest file is created with the MigrateResult of a computeMigration run. +class MigrateManifest { + /// Creates a new manifest from a MigrateResult. + MigrateManifest({ + required this.migrateRootDir, + required this.migrateResult, + }); + + /// Parses an existing migrate manifest. + MigrateManifest.fromFile(File manifestFile) : migrateResult = MigrateResult.empty(), migrateRootDir = manifestFile.parent { + final Object? yamlContents = loadYaml(manifestFile.readAsStringSync()); + if (yamlContents is! YamlMap) { + throw Exception('Invalid .migrate_manifest file in the migrate working directory. File is not a Yaml map.'); + } + final YamlMap map = yamlContents; + bool valid = map.containsKey(_kMergedFilesKey) && map.containsKey(_kConflictFilesKey) && map.containsKey(_kAddedFilesKey) && map.containsKey(_kDeletedFilesKey); + if (!valid) { + throw Exception('Invalid .migrate_manifest file in the migrate working directory. File is missing an entry.'); + } + final Object? mergedFilesYaml = map[_kMergedFilesKey]; + final Object? conflictFilesYaml = map[_kConflictFilesKey]; + final Object? addedFilesYaml = map[_kAddedFilesKey]; + final Object? deletedFilesYaml = map[_kDeletedFilesKey]; + valid = valid && (mergedFilesYaml is YamlList || mergedFilesYaml == null); + valid = valid && (conflictFilesYaml is YamlList || conflictFilesYaml == null); + valid = valid && (addedFilesYaml is YamlList || addedFilesYaml == null); + valid = valid && (deletedFilesYaml is YamlList || deletedFilesYaml == null); + if (!valid) { + throw Exception('Invalid .migrate_manifest file in the migrate working directory. Entry is not a Yaml list.'); + } + if (mergedFilesYaml != null) { + for (final Object? localPath in mergedFilesYaml as YamlList) { + if (localPath is String) { + // We can fill the maps with partially dummy data as not all properties are used by the manifest. + migrateResult.mergeResults.add(StringMergeResult.explicit(mergedString: '', hasConflict: false, exitCode: 0, localPath: localPath)); + } + } + } + if (conflictFilesYaml != null) { + for (final Object? localPath in conflictFilesYaml as YamlList) { + if (localPath is String) { + migrateResult.mergeResults.add(StringMergeResult.explicit(mergedString: '', hasConflict: true, exitCode: 1, localPath: localPath)); + } + } + } + if (addedFilesYaml != null) { + for (final Object? localPath in addedFilesYaml as YamlList) { + if (localPath is String) { + migrateResult.addedFiles.add(FilePendingMigration(localPath, migrateRootDir.childFile(localPath))); + } + } + } + if (deletedFilesYaml != null) { + for (final Object? localPath in deletedFilesYaml as YamlList) { + if (localPath is String) { + migrateResult.deletedFiles.add(FilePendingMigration(localPath, migrateRootDir.childFile(localPath))); + } + } + } + } + + final Directory migrateRootDir; + final MigrateResult migrateResult; + + /// A list of local paths of files that require conflict resolution. + List get conflictFiles { + final List output = []; + for (final MergeResult result in migrateResult.mergeResults) { + if (result.hasConflict) { + output.add(result.localPath); + } + } + return output; + } + + /// A list of local paths of files that require conflict resolution. + List remainingConflictFiles(Directory workingDir) { + final List output = []; + for (final String localPath in conflictFiles) { + if (!_conflictsResolved(workingDir.childFile(localPath).readAsStringSync())) { + output.add(localPath); + } + } + return output; + } + + // A list of local paths of files that had conflicts and are now fully resolved. + List resolvedConflictFiles(Directory workingDir) { + final List output = []; + for (final String localPath in conflictFiles) { + if (_conflictsResolved(workingDir.childFile(localPath).readAsStringSync())) { + output.add(localPath); + } + } + return output; + } + + /// A list of local paths of files that were automatically merged. + List get mergedFiles { + final List output = []; + for (final MergeResult result in migrateResult.mergeResults) { + if (!result.hasConflict) { + output.add(result.localPath); + } + } + return output; + } + + /// A list of local paths of files that were newly added. + List get addedFiles { + final List output = []; + for (final FilePendingMigration file in migrateResult.addedFiles) { + output.add(file.localPath); + } + return output; + } + + /// A list of local paths of files that are marked for deletion. + List get deletedFiles { + final List output = []; + for (final FilePendingMigration file in migrateResult.deletedFiles) { + output.add(file.localPath); + } + return output; + } + + /// Returns the manifest file given a migration workind directory. + static File getManifestFileFromDirectory(Directory workingDir) { + return workingDir.childFile('.migrate_manifest'); + } + + /// Writes the manifest yaml file in the working directory. + void writeFile() { + final StringBuffer mergedFileManifestContents = StringBuffer(); + final StringBuffer conflictFilesManifestContents = StringBuffer(); + for (final MergeResult result in migrateResult.mergeResults) { + if (result.hasConflict) { + conflictFilesManifestContents.write(' - ${result.localPath}\n'); + } else { + mergedFileManifestContents.write(' - ${result.localPath}\n'); + } + } + + final StringBuffer newFileManifestContents = StringBuffer(); + for (final String localPath in addedFiles) { + newFileManifestContents.write(' - $localPath\n)'); + } + + final StringBuffer deletedFileManifestContents = StringBuffer(); + for (final String localPath in deletedFiles) { + deletedFileManifestContents.write(' - $localPath\n'); + } + + final String migrateManifestContents = 'merged_files:\n${mergedFileManifestContents.toString()}conflict_files:\n${conflictFilesManifestContents.toString()}added_files:\n${newFileManifestContents.toString()}deleted_files:\n${deletedFileManifestContents.toString()}'; + final File migrateManifest = getManifestFileFromDirectory(migrateRootDir); + migrateManifest.createSync(recursive: true); + migrateManifest.writeAsStringSync(migrateManifestContents, flush: true); + } +} + +/// Returns true if the file does not contain any git conflict markers. +bool _conflictsResolved(String contents) { + if (contents.contains('>>>>>>>') && contents.contains('=======') && contents.contains('<<<<<<<')) { + return false; + } + return true; +} + +/// Returns true if the migration working directory has all conflicts resolved and prints the migration status. +/// +/// The migration status printout lists all added, deleted, merged, and conflicted files. +bool checkAndPrintMigrateStatus(MigrateManifest manifest, Directory workingDir, {bool warnConflict = false, Logger? logger}) { + final StringBuffer printout = StringBuffer(); + final StringBuffer redPrintout = StringBuffer(); + bool result = true; + final List remainingConflicts = []; + final List mergedFiles = []; + for (final String localPath in manifest.conflictFiles) { + if (!_conflictsResolved(workingDir.childFile(localPath).readAsStringSync())) { + remainingConflicts.add(localPath); + } else { + mergedFiles.add(localPath); + } + } + + mergedFiles.addAll(manifest.mergedFiles); + if (manifest.addedFiles.isNotEmpty) { + printout.write('Added files:\n'); + for (final String localPath in manifest.addedFiles) { + printout.write(' - $localPath\n'); + } + } + if (manifest.deletedFiles.isNotEmpty) { + printout.write('Deleted files:\n'); + for (final String localPath in manifest.deletedFiles) { + printout.write(' - $localPath\n'); + } + } + if (mergedFiles.isNotEmpty) { + printout.write('Modified files:\n'); + for (final String localPath in mergedFiles) { + printout.write(' - $localPath\n'); + } + } + if (remainingConflicts.isNotEmpty) { + if (warnConflict) { + printout.write('Unable to apply migration. The following files in the migration working directory still have unresolved conflicts:'); + } else { + printout.write('Merge conflicted files:'); + } + for (final String localPath in remainingConflicts) { + redPrintout.write(' - $localPath\n'); + } + result = false; + } + if (logger != null) { + logger.printStatus(printout.toString()); + logger.printStatus(redPrintout.toString(), color: TerminalColor.red, newline: false); + } + return result; +} diff --git a/packages/flutter_tools/lib/src/migrate/migrate_result.dart b/packages/flutter_tools/lib/src/migrate/migrate_result.dart new file mode 100644 index 00000000000..ef930bcddac --- /dev/null +++ b/packages/flutter_tools/lib/src/migrate/migrate_result.dart @@ -0,0 +1,82 @@ +// 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 '../base/file_system.dart'; +import 'migrate_utils.dart'; + +/// Data class that holds all results and generated directories from a computeMigration run. +/// +/// mergeResults, addedFiles, and deletedFiles includes the sets of files to be migrated while +/// the other members track the temporary sdk and generated app directories created by the tool. +/// +/// The compute function does not clean up the temp directories, as the directories may be reused, +/// so this must be done manually afterwards. +class MigrateResult { + /// Explicitly initialize the MigrateResult. + MigrateResult({ + required this.mergeResults, + required this.addedFiles, + required this.deletedFiles, + required this.tempDirectories, + required this.sdkDirs, + required this.mergeTypeMap, + required this.diffMap, + this.generatedBaseTemplateDirectory, + this.generatedTargetTemplateDirectory}); + + /// Creates a MigrateResult with all empty members. + MigrateResult.empty() + : mergeResults = [], + addedFiles = [], + deletedFiles = [], + tempDirectories = [], + mergeTypeMap = {}, + diffMap = {}, + sdkDirs = {}; + + /// The results of merging existing files with the target files. + final List mergeResults; + + /// Tracks the files that are to be newly added to the project. + final List addedFiles; + + /// Tracks the files that are to be deleted from the project. + final List deletedFiles; + + /// Tracks the temporary directories created during the migrate compute process. + final List tempDirectories; + + /// Mapping between the local path of a file and the type of merge that should be used. + final Map mergeTypeMap; + + /// Mapping between the local path of a file and the diff between the base and target + /// versions of the file. + final Map diffMap; + + /// The root directory of the base app. + Directory? generatedBaseTemplateDirectory; + + /// The root directory of the target app. + Directory? generatedTargetTemplateDirectory; + + /// The root directories of the Flutter SDK for each revision. + Map sdkDirs; +} + +/// Defines available merge techniques. +enum MergeType { + /// A standard three-way merge. + threeWay, + /// A two way merge that ignores the base version of the file. + twoWay, + /// A `CustomMerge` manually handles the merge. + custom, +} + +/// Stores a file that has been marked for migration and metadata about the file. +class FilePendingMigration { + FilePendingMigration(this.localPath, this.file); + String localPath; + File file; +} diff --git a/packages/flutter_tools/lib/src/migrate/migrate_utils.dart b/packages/flutter_tools/lib/src/migrate/migrate_utils.dart new file mode 100644 index 00000000000..b6b57313cc5 --- /dev/null +++ b/packages/flutter_tools/lib/src/migrate/migrate_utils.dart @@ -0,0 +1,359 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:process/process.dart'; + +import '../base/common.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/platform.dart'; +import '../base/process.dart'; + +/// The default name of the migrate working directory used to stage proposed changes. +const String kDefaultMigrateWorkingDirectoryName = 'migrate_working_dir'; + +/// Utility class that contains methods that wrap git and other shell commands. +class MigrateUtils { + MigrateUtils({ + required Logger logger, + required FileSystem fileSystem, + required Platform platform, + required ProcessManager processManager, + }) : + _processUtils = ProcessUtils(processManager: processManager, logger: logger), + _logger = logger, + _fileSystem = fileSystem, + _platform = platform; + + final Logger _logger; + final FileSystem _fileSystem; + final Platform _platform; + final ProcessUtils _processUtils; + + /// Calls `git diff` on two files and returns the diff as a DiffResult. + Future 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 cmdArgs = ['git', 'diff', '--no-index', one.absolute.path, two.absolute.path]; + final RunResult result = await _processUtils.run(cmdArgs); + + // diff exits with 1 if diffs are found. + checkForErrors(result, allowedExitCodes: [0, 1], commandDescription: 'git ${cmdArgs.join(' ')}'); + return DiffResult(diffType: DiffType.command, diff: result.stdout, exitCode: result.exitCode); + } + + /// Clones a copy of the flutter repo into the destination directory. Returns false if unsuccessful. + Future cloneFlutter(String revision, String destination) async { + // Use https url instead of ssh to avoid need to setup ssh on git. + List cmdArgs = ['git', 'clone', '--filter=blob:none', 'https://github.com/flutter/flutter.git', destination]; + RunResult result = await _processUtils.run(cmdArgs); + checkForErrors(result, commandDescription: cmdArgs.join(' ')); + + cmdArgs.clear(); + cmdArgs = ['git', 'reset', '--hard', revision]; + result = await _processUtils.run(cmdArgs, workingDirectory: destination); + if (!checkForErrors(result, commandDescription: cmdArgs.join(' '), exit: false)) { + return false; + } + return true; + } + + /// Calls `flutter create` as a re-entrant command. + Future createFromTemplates(String flutterBinPath, { + required String name, + bool legacyNameParameter = false, + required String androidLanguage, + required String iosLanguage, + required String outputDirectory, + String? createVersion, + List platforms = const [], + 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 cmdArgs = ['$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 RunResult result = await _processUtils.run(cmdArgs, workingDirectory: outputDirectory, allowReentrantFlutter: true); + final String error = result.stderr; + + // 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).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 gitMergeFile({ + required String base, + required String current, + required String target, + required String localPath, + }) async { + final List cmdArgs = ['git', 'merge-file', '-p', current, base, target]; + final RunResult result = await _processUtils.run(cmdArgs); + checkForErrors(result, allowedExitCodes: [-1], commandDescription: cmdArgs.join(' ')); + return StringMergeResult(result, localPath); + } + + /// Calls `git init` on the workingDirectory. + Future gitInit(String workingDirectory) async { + final List cmdArgs = ['git', 'init']; + final RunResult result = await _processUtils.run(cmdArgs, workingDirectory: workingDirectory); + checkForErrors(result, commandDescription: cmdArgs.join(' ')); + } + + /// Returns true if the workingDirectory git repo has any uncommited changes. + Future hasUncommittedChanges(String workingDirectory, {String? migrateWorkingDir}) async { + final List cmdArgs = [ + 'git', + 'ls-files', + '--deleted', + '--modified', + '--others', + '--exclude=${migrateWorkingDir ?? kDefaultMigrateWorkingDirectoryName}' + ]; + final RunResult result = await _processUtils.run(cmdArgs, workingDirectory: workingDirectory); + checkForErrors(result, allowedExitCodes: [-1], commandDescription: cmdArgs.join(' ')); + if (result.stdout.isEmpty) { + return false; + } + return true; + } + + /// Returns true if the workingDirectory is a git repo. + Future isGitRepo(String workingDirectory) async { + final List cmdArgs = ['git', 'rev-parse', '--is-inside-work-tree']; + final RunResult result = await _processUtils.run(cmdArgs, workingDirectory: workingDirectory); + checkForErrors(result, allowedExitCodes: [-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 isGitIgnored(String filePath, String workingDirectory) async { + final List cmdArgs = ['git', 'check-ignore', filePath]; + final RunResult result = await _processUtils.run(cmdArgs, workingDirectory: workingDirectory); + checkForErrors(result, allowedExitCodes: [0, 1, 128], commandDescription: cmdArgs.join(' ')); + return result.exitCode == 0; + } + + /// Runs `flutter pub upgrade --major-revisions`. + Future flutterPubUpgrade(String workingDirectory) async { + final List cmdArgs = ['flutter', 'pub', 'upgrade', '--major-versions']; + final RunResult result = await _processUtils.run(cmdArgs, workingDirectory: workingDirectory, allowReentrantFlutter: true); + checkForErrors(result, commandDescription: cmdArgs.join(' ')); + } + + /// Runs `./gradlew tasks` in the android directory of a flutter project. + Future gradlewTasks(String workingDirectory) async { + final String baseCommand = _platform.isWindows ? 'gradlew.bat' : './gradlew'; + final List cmdArgs = [baseCommand, 'tasks']; + final RunResult result = await _processUtils.run(cmdArgs, workingDirectory: workingDirectory); + checkForErrors(result, commandDescription: cmdArgs.join(' ')); + } + + /// Verifies that the RunResult 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( + RunResult result, { + List allowedExitCodes = const [0], + String? commandDescription, + bool exit = true, + bool silent = false + }) { + if (!allowedExitCodes.contains(result.exitCode) && !allowedExitCodes.contains(-1)) { + 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, indent: 2); + _logger.printError('Stderr:'); + _logger.printError(result.stderr, indent: 2); + } + if (exit) { + throwToolExit('Command failed with exit code ${result.exitCode}', exitCode: result.exitCode); + } + return false; + } + return true; + } + + /// Returns true if the file does not contain any git conflit markers. + bool conflictsResolved(String contents) { + if (contents.contains('>>>>>>>') && contents.contains('=======') && contents.contains('<<<<<<<')) { + return false; + } + return true; + } +} + +/// 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 RunResult. + MergeResult(RunResult 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 RunResult. + StringMergeResult(super.result, super.localPath) : + mergedString = result.stdout; + + /// 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 RunResult. + 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; +} diff --git a/packages/flutter_tools/test/general.shard/migrate/migrate_manifest_test.dart b/packages/flutter_tools/test/general.shard/migrate/migrate_manifest_test.dart new file mode 100644 index 00000000000..ab166547a11 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/migrate/migrate_manifest_test.dart @@ -0,0 +1,292 @@ +// 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 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/migrate/migrate_manifest.dart'; +import 'package:flutter_tools/src/migrate/migrate_result.dart'; +import 'package:flutter_tools/src/migrate/migrate_utils.dart'; + +import '../../src/common.dart'; + +void main() { + late FileSystem fileSystem; + late File manifestFile; + + setUpAll(() { + fileSystem = MemoryFileSystem.test(); + manifestFile = fileSystem.file('.migrate_manifest'); + }); + + group('manifest file parsing', () { + testWithoutContext('empty fails', () async { + manifestFile.writeAsStringSync(''); + bool exceptionFound = false; + try { + MigrateManifest.fromFile(manifestFile); + } on Exception catch (e) { + exceptionFound = true; + expect(e.toString(), 'Exception: Invalid .migrate_manifest file in the migrate working directory. File is not a Yaml map.'); + } + expect(exceptionFound, true); + }); + + testWithoutContext('invalid name fails', () async { + manifestFile.writeAsStringSync(''' + merged_files: + conflict_files: + added_filessssss: + deleted_files: + '''); + bool exceptionFound = false; + try { + MigrateManifest.fromFile(manifestFile); + } on Exception catch (e) { + exceptionFound = true; + expect(e.toString(), 'Exception: Invalid .migrate_manifest file in the migrate working directory. File is missing an entry.'); + } + expect(exceptionFound, true); + }); + + testWithoutContext('missing name fails', () async { + manifestFile.writeAsStringSync(''' + merged_files: + conflict_files: + deleted_files: + '''); + bool exceptionFound = false; + try { + MigrateManifest.fromFile(manifestFile); + } on Exception catch (e) { + exceptionFound = true; + expect(e.toString(), 'Exception: Invalid .migrate_manifest file in the migrate working directory. File is missing an entry.'); + } + expect(exceptionFound, true); + }); + + testWithoutContext('wrong entry type fails', () async { + manifestFile.writeAsStringSync(''' + merged_files: + conflict_files: + other_key: + added_files: + deleted_files: + '''); + bool exceptionFound = false; + try { + MigrateManifest.fromFile(manifestFile); + } on Exception catch (e) { + exceptionFound = true; + expect(e.toString(), 'Exception: Invalid .migrate_manifest file in the migrate working directory. Entry is not a Yaml list.'); + } + expect(exceptionFound, true); + }); + + testWithoutContext('unpopulated succeeds', () async { + manifestFile.writeAsStringSync(''' + merged_files: + conflict_files: + added_files: + deleted_files: + '''); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + expect(manifest.mergedFiles.isEmpty, true); + expect(manifest.conflictFiles.isEmpty, true); + expect(manifest.addedFiles.isEmpty, true); + expect(manifest.deletedFiles.isEmpty, true); + }); + + testWithoutContext('order does not matter', () async { + manifestFile.writeAsStringSync(''' + added_files: + merged_files: + deleted_files: + conflict_files: + '''); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + expect(manifest.mergedFiles.isEmpty, true); + expect(manifest.conflictFiles.isEmpty, true); + expect(manifest.addedFiles.isEmpty, true); + expect(manifest.deletedFiles.isEmpty, true); + }); + + testWithoutContext('basic succeeds', () async { + manifestFile.writeAsStringSync(''' + merged_files: + - file1 + conflict_files: + - file2 + added_files: + - file3 + deleted_files: + - file4 + '''); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + expect(manifest.mergedFiles.isEmpty, false); + expect(manifest.conflictFiles.isEmpty, false); + expect(manifest.addedFiles.isEmpty, false); + expect(manifest.deletedFiles.isEmpty, false); + + expect(manifest.mergedFiles.length, 1); + expect(manifest.conflictFiles.length, 1); + expect(manifest.addedFiles.length, 1); + expect(manifest.deletedFiles.length, 1); + + expect(manifest.mergedFiles[0], 'file1'); + expect(manifest.conflictFiles[0], 'file2'); + expect(manifest.addedFiles[0], 'file3'); + expect(manifest.deletedFiles[0], 'file4'); + }); + + testWithoutContext('basic multi-list succeeds', () async { + manifestFile.writeAsStringSync(''' + merged_files: + - file1 + - file2 + conflict_files: + added_files: + deleted_files: + - file3 + - file4 + '''); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + expect(manifest.mergedFiles.isEmpty, false); + expect(manifest.conflictFiles.isEmpty, true); + expect(manifest.addedFiles.isEmpty, true); + expect(manifest.deletedFiles.isEmpty, false); + + expect(manifest.mergedFiles.length, 2); + expect(manifest.conflictFiles.length, 0); + expect(manifest.addedFiles.length, 0); + expect(manifest.deletedFiles.length, 2); + + expect(manifest.mergedFiles[0], 'file1'); + expect(manifest.mergedFiles[1], 'file2'); + expect(manifest.deletedFiles[0], 'file3'); + expect(manifest.deletedFiles[1], 'file4'); + }); + }); + + group('manifest MigrateResult creation', () { + testWithoutContext('empty MigrateResult', () async { + final MigrateManifest manifest = MigrateManifest(migrateRootDir: fileSystem.directory('root'), migrateResult: MigrateResult( + mergeResults: [], + addedFiles: [], + deletedFiles: [], + mergeTypeMap: {}, + diffMap: {}, + tempDirectories: [], + sdkDirs: {}, + )); + expect(manifest.mergedFiles.isEmpty, true); + expect(manifest.conflictFiles.isEmpty, true); + expect(manifest.addedFiles.isEmpty, true); + expect(manifest.deletedFiles.isEmpty, true); + }); + + testWithoutContext('simple MigrateResult', () async { + final MigrateManifest manifest = MigrateManifest(migrateRootDir: fileSystem.directory('root'), migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: '<<<<<<<<<<<', + hasConflict: true, + exitCode: 1, + ), + ], + addedFiles: [FilePendingMigration('added_file', fileSystem.file('added_file'))], + deletedFiles: [FilePendingMigration('deleted_file', fileSystem.file('deleted_file'))], + // The following are ignored by the manifest. + mergeTypeMap: {'test': MergeType.threeWay}, + diffMap: {}, + tempDirectories: [], + sdkDirs: {}, + )); + expect(manifest.mergedFiles.isEmpty, false); + expect(manifest.conflictFiles.isEmpty, false); + expect(manifest.addedFiles.isEmpty, false); + expect(manifest.deletedFiles.isEmpty, false); + + expect(manifest.mergedFiles.length, 1); + expect(manifest.conflictFiles.length, 1); + expect(manifest.addedFiles.length, 1); + expect(manifest.deletedFiles.length, 1); + + expect(manifest.mergedFiles[0], 'merged_file'); + expect(manifest.conflictFiles[0], 'conflict_file'); + expect(manifest.addedFiles[0], 'added_file'); + expect(manifest.deletedFiles[0], 'deleted_file'); + }); + }); + + group('manifest write', () { + testWithoutContext('empty', () async { + manifestFile.writeAsStringSync(''' + merged_files: + conflict_files: + added_files: + deleted_files: + '''); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + expect(manifest.mergedFiles.isEmpty, true); + expect(manifest.conflictFiles.isEmpty, true); + expect(manifest.addedFiles.isEmpty, true); + expect(manifest.deletedFiles.isEmpty, true); + + manifest.writeFile(); + expect(manifestFile.readAsStringSync(), ''' +merged_files: +conflict_files: +added_files: +deleted_files: +'''); + }); + + testWithoutContext('basic multi-list', () async { + manifestFile.writeAsStringSync(''' + merged_files: + - file1 + - file2 + conflict_files: + added_files: + deleted_files: + - file3 + - file4 + '''); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + expect(manifest.mergedFiles.isEmpty, false); + expect(manifest.conflictFiles.isEmpty, true); + expect(manifest.addedFiles.isEmpty, true); + expect(manifest.deletedFiles.isEmpty, false); + + expect(manifest.mergedFiles.length, 2); + expect(manifest.conflictFiles.length, 0); + expect(manifest.addedFiles.length, 0); + expect(manifest.deletedFiles.length, 2); + + expect(manifest.mergedFiles[0], 'file1'); + expect(manifest.mergedFiles[1], 'file2'); + expect(manifest.deletedFiles[0], 'file3'); + expect(manifest.deletedFiles[1], 'file4'); + + manifest.writeFile(); + expect(manifestFile.readAsStringSync(), ''' +merged_files: + - file1 + - file2 +conflict_files: +added_files: +deleted_files: + - file3 + - file4 +'''); + }); + }); +} diff --git a/packages/flutter_tools/test/integration.shard/migrate_utils_test.dart b/packages/flutter_tools/test/integration.shard/migrate_utils_test.dart new file mode 100644 index 00000000000..5c819e82c59 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/migrate_utils_test.dart @@ -0,0 +1,223 @@ +// 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. + +// @dart = 2.8 + +import 'package:file/file.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/migrate/migrate_utils.dart'; + +import '../src/common.dart'; + +void main() { + BufferLogger logger; + FileSystem fileSystem; + Directory projectRoot; + String projectRootPath; + MigrateUtils utils; + ProcessUtils processUtils; + + setUpAll(() async { + fileSystem = globals.localFileSystem; + logger = BufferLogger.test(); + utils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + platform: globals.platform, + processManager: globals.processManager, + ); + processUtils = ProcessUtils(processManager: globals.processManager, logger: logger); + }); + + group('git', () { + setUp(() async { + projectRoot = fileSystem.systemTempDirectory.createTempSync('flutter_migrate_utils_test'); + projectRoot.createSync(recursive: true); + projectRootPath = projectRoot.path; + }); + + testWithoutContext('init', () async { + expect(projectRoot.existsSync(), true); + expect(projectRoot.childDirectory('.git').existsSync(), false); + await utils.gitInit(projectRootPath); + expect(projectRoot.childDirectory('.git').existsSync(), true); + }); + + testWithoutContext('isGitIgnored', () async { + expect(projectRoot.existsSync(), true); + expect(projectRoot.childDirectory('.git').existsSync(), false); + await utils.gitInit(projectRootPath); + expect(projectRoot.childDirectory('.git').existsSync(), true); + + projectRoot.childFile('.gitignore') + ..createSync() + ..writeAsStringSync('ignored_file.dart', flush: true); + + expect(await utils.isGitIgnored('ignored_file.dart', projectRootPath), true); + expect(await utils.isGitIgnored('other_file.dart', projectRootPath), false); + }); + + testWithoutContext('isGitRepo', () async { + expect(projectRoot.existsSync(), true); + expect(projectRoot.childDirectory('.git').existsSync(), false); + expect(await utils.isGitRepo(projectRootPath), false); + await utils.gitInit(projectRootPath); + expect(projectRoot.childDirectory('.git').existsSync(), true); + + expect(await utils.isGitRepo(projectRootPath), true); + + expect(await utils.isGitRepo(projectRoot.parent.path), false); + }); + + testWithoutContext('hasUncommittedChanges false on clean repo', () async { + expect(projectRoot.existsSync(), true); + expect(projectRoot.childDirectory('.git').existsSync(), false); + await utils.gitInit(projectRootPath); + expect(projectRoot.childDirectory('.git').existsSync(), true); + + projectRoot.childFile('.gitignore') + ..createSync() + ..writeAsStringSync('ignored_file.dart', flush: true); + + await processUtils.run(['git', 'add', '.'], workingDirectory: projectRootPath); + await processUtils.run(['git', 'commit', '-m', 'Initial commit'], workingDirectory: projectRootPath); + + expect(await utils.hasUncommittedChanges(projectRootPath), false); + }); + + testWithoutContext('hasUncommittedChanges true on dirty repo', () async { + expect(projectRoot.existsSync(), true); + expect(projectRoot.childDirectory('.git').existsSync(), false); + await utils.gitInit(projectRootPath); + expect(projectRoot.childDirectory('.git').existsSync(), true); + + projectRoot.childFile('some_file.dart') + ..createSync() + ..writeAsStringSync('void main() {}', flush: true); + + expect(await utils.hasUncommittedChanges(projectRootPath), true); + }); + + testWithoutContext('diffFiles', () async { + expect(projectRoot.existsSync(), true); + expect(projectRoot.childDirectory('.git').existsSync(), false); + await utils.gitInit(projectRootPath); + expect(projectRoot.childDirectory('.git').existsSync(), true); + + final File file1 = projectRoot.childFile('some_file.dart') + ..createSync() + ..writeAsStringSync('void main() {}\n', flush: true); + + final File file2 = projectRoot.childFile('some_other_file.dart'); + + DiffResult result = await utils.diffFiles(file1, file2); + expect(result.diff, null); + expect(result.diffType, DiffType.deletion); + expect(result.exitCode, null); + + result = await utils.diffFiles(file2, file1); + expect(result.diff, null); + expect(result.diffType, DiffType.addition); + expect(result.exitCode, null); + + file2.createSync(); + file2.writeAsStringSync('void main() {}\n', flush: true); + + result = await utils.diffFiles(file1, file2); + expect(result.diff, ''); + expect(result.diffType, DiffType.command); + expect(result.exitCode, 0); + + file2.writeAsStringSync('void main() {}\na second line\na third line\n', flush: true); + + result = await utils.diffFiles(file1, file2); + expect(result.diff, contains('@@ -1 +1,3 @@\n void main() {}\n+a second line\n+a third line')); + expect(result.diffType, DiffType.command); + expect(result.exitCode, 1); + }); + + testWithoutContext('merge', () async { + expect(projectRoot.existsSync(), true); + expect(projectRoot.childDirectory('.git').existsSync(), false); + await utils.gitInit(projectRootPath); + expect(projectRoot.childDirectory('.git').existsSync(), true); + + final File file1 = projectRoot.childFile('some_file.dart'); + file1.createSync(); + file1.writeAsStringSync('void main() {}\n\nline1\nline2\nline3\nline4\nline5\n', flush: true); + final File file2 = projectRoot.childFile('some_other_file.dart'); + file2.createSync(); + file2.writeAsStringSync('void main() {}\n\nline1\nline2\nline3.0\nline3.5\nline4\nline5\n', flush: true); + final File file3 = projectRoot.childFile('some_other_third_file.dart'); + file3.createSync(); + file3.writeAsStringSync('void main() {}\n\nline2\nline3\nline4\nline5\n', flush: true); + + StringMergeResult result = await utils.gitMergeFile( + base: file1.path, + current: file2.path, + target: file3.path, + localPath: 'some_file.dart', + ) as StringMergeResult; + + expect(result.mergedString, 'void main() {}\n\nline2\nline3.0\nline3.5\nline4\nline5\n'); + expect(result.hasConflict, false); + expect(result.exitCode, 0); + + file3.writeAsStringSync('void main() {}\n\nline1\nline2\nline3.1\nline3.5\nline4\nline5\n', flush: true); + + result = await utils.gitMergeFile( + base: file1.path, + current: file2.path, + target: file3.path, + localPath: 'some_file.dart', + ) as StringMergeResult; + + expect(result.mergedString, contains('line3.0\n=======\nline3.1\n>>>>>>>')); + expect(result.hasConflict, true); + expect(result.exitCode, 1); + + // Two way merge + result = await utils.gitMergeFile( + base: file1.path, + current: file1.path, + target: file3.path, + localPath: 'some_file.dart', + ) as StringMergeResult; + + expect(result.mergedString, 'void main() {}\n\nline1\nline2\nline3.1\nline3.5\nline4\nline5\n'); + expect(result.hasConflict, false); + expect(result.exitCode, 0); + }); + }); + + group('legacy app creation', () { + testWithoutContext('clone and create', () async { + projectRoot = fileSystem.systemTempDirectory.createTempSync('flutter_sdk_test'); + const String revision = '5391447fae6209bb21a89e6a5a6583cac1af9b4b'; + + expect(await utils.cloneFlutter(revision, projectRoot.path), true); + expect(projectRoot.childFile('README.md').existsSync(), true); + + final Directory appDir = fileSystem.systemTempDirectory.createTempSync('flutter_app'); + await utils.createFromTemplates( + projectRoot.childDirectory('bin').path, + name: 'testapp', + androidLanguage: 'java', + iosLanguage: 'objc', + outputDirectory: appDir.path, + ); + expect(appDir.childFile('pubspec.yaml').existsSync(), true); + expect(appDir.childFile('.metadata').existsSync(), true); + expect(appDir.childFile('.metadata').readAsStringSync(), contains(revision)); + expect(appDir.childDirectory('android').existsSync(), true); + expect(appDir.childDirectory('ios').existsSync(), true); + expect(appDir.childDirectory('web').existsSync(), false); + + projectRoot.deleteSync(recursive: true); + }); + }); +}