diff --git a/packages/flutter_migrate/bin/flutter_migrate b/packages/flutter_migrate/bin/flutter_migrate new file mode 100755 index 00000000000..bae658aecb2 --- /dev/null +++ b/packages/flutter_migrate/bin/flutter_migrate @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# 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. + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +dart run $SCRIPT_DIR/flutter_migrate.dart "$@" diff --git a/packages/flutter_migrate/bin/flutter_migrate.bat b/packages/flutter_migrate/bin/flutter_migrate.bat new file mode 100755 index 00000000000..b845731cc41 --- /dev/null +++ b/packages/flutter_migrate/bin/flutter_migrate.bat @@ -0,0 +1,29 @@ +@ECHO off +REM Copyright 2014 The Flutter Authors. All rights reserved. +REM Use of this source code is governed by a BSD-style license that can be +REM found in the LICENSE file. + +REM If available, add location of bundled mingit to PATH +SET mingit_path=%FLUTTER_ROOT%\bin\mingit\cmd +IF EXIST "%mingit_path%" SET PATH=%PATH%;%mingit_path% + +REM Test if Git is available on the Host +where /q git || ECHO Error: Unable to find git in your PATH. && EXIT /B 1 + +rem SET flutter_tools_dir=%FLUTTER_ROOT%\packages\flutter_tools +SET cache_dir=%FLUTTER_ROOT%\bin\cache +rem SET snapshot_path=%cache_dir%\flutter_tools.snapshot +SET dart_sdk_path=%cache_dir%\dart-sdk +SET dart=%dart_sdk_path%\bin\dart.exe + +SET exit_with_errorlevel=%FLUTTER_ROOT%/bin/internal/exit_with_errorlevel.bat + +REM Chaining the call to 'dart' and 'exit' with an ampersand ensures that +REM Windows reads both commands into memory once before executing them. This +REM avoids nasty errors that may otherwise occur when the dart command (e.g. as +REM part of 'flutter upgrade') modifies this batch script while it is executing. +REM +REM Do not use the CALL command in the next line to execute Dart. CALL causes +REM Windows to re-read the line from disk after the CALL command has finished +REM regardless of the ampersand chain. +"%dart%" --disable-dart-dev --packages="%flutter_tools_dir%\.dart_tool\package_config.json" %FLUTTER_TOOL_ARGS% "%snapshot_path%" %* & "%exit_with_errorlevel%" diff --git a/packages/flutter_migrate/bin/flutter_migrate.dart b/packages/flutter_migrate/bin/flutter_migrate.dart new file mode 100644 index 00000000000..360ada657c9 --- /dev/null +++ b/packages/flutter_migrate/bin/flutter_migrate.dart @@ -0,0 +1,11 @@ +// 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.9 + +import 'package:flutter_migrate/executable.dart' as executable; + +void main(List args) { + executable.main(args); +} diff --git a/packages/flutter_migrate/lib/executable.dart b/packages/flutter_migrate/lib/executable.dart new file mode 100644 index 00000000000..d97bc5a70a6 --- /dev/null +++ b/packages/flutter_migrate/lib/executable.dart @@ -0,0 +1,133 @@ +// 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:io'; + +import 'package:process/process.dart'; + +import 'src/base/command.dart'; +import 'src/base/common.dart'; +import 'src/base/file_system.dart'; +import 'src/base/io.dart'; +import 'src/base/logger.dart'; +import 'src/base/signals.dart'; +import 'src/base/terminal.dart'; + +import 'src/commands/abandon.dart'; +import 'src/commands/apply.dart'; +import 'src/commands/resolve_conflicts.dart'; +import 'src/commands/start.dart'; +import 'src/commands/status.dart'; + +Future main(List args) async { + final bool veryVerbose = args.contains('-vv'); + final bool verbose = + args.contains('-v') || args.contains('--verbose') || veryVerbose; + + const ProcessManager localProcessManager = LocalProcessManager(); + final FileSystem fileSystem = LocalFileSystem( + LocalSignals.instance, Signals.defaultExitSignals, ShutdownHooks()); + + // flutterRoot must be set early because other features use it (e.g. + // enginePath's initializer uses it). This can only work with the real + // instances of the platform or filesystem, so just use those. + flutterRoot = defaultFlutterRoot(fileSystem: fileSystem); + + final Stdio stdio = Stdio(); + final Terminal terminal = AnsiTerminal(stdio: stdio); + + final LoggerFactory loggerFactory = LoggerFactory( + outputPreferences: OutputPreferences( + wrapText: stdio.hasTerminal, + showColor: stdout.supportsAnsiEscapes, + stdio: stdio, + ), + terminal: terminal, + stdio: stdio, + ); + final Logger logger = loggerFactory.createLogger( + windows: isWindows, + ); + + final List commands = [ + MigrateStartCommand( + verbose: verbose, + logger: logger, + fileSystem: fileSystem, + processManager: localProcessManager, + ), + MigrateStatusCommand( + verbose: verbose, + logger: logger, + fileSystem: fileSystem, + processManager: localProcessManager, + ), + MigrateResolveConflictsCommand( + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + ), + MigrateAbandonCommand( + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + processManager: localProcessManager), + MigrateApplyCommand( + verbose: verbose, + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + processManager: localProcessManager), + ]; + + for (final MigrateCommand command in commands) { + if (command.name == args[0]) { + command.run(); + break; + } + } +} + +/// An abstraction for instantiation of the correct logger type. +/// +/// Our logger class hierarchy and runtime requirements are overly complicated. +class LoggerFactory { + LoggerFactory({ + required Terminal terminal, + required Stdio stdio, + required OutputPreferences outputPreferences, + StopwatchFactory stopwatchFactory = const StopwatchFactory(), + }) : _terminal = terminal, + _stdio = stdio, + _stopwatchFactory = stopwatchFactory, + _outputPreferences = outputPreferences; + + final Terminal _terminal; + final Stdio _stdio; + final StopwatchFactory _stopwatchFactory; + final OutputPreferences _outputPreferences; + + /// Create the appropriate logger for the current platform and configuration. + Logger createLogger({ + required bool windows, + }) { + Logger logger; + if (windows) { + logger = WindowsStdoutLogger( + terminal: _terminal, + stdio: _stdio, + outputPreferences: _outputPreferences, + stopwatchFactory: _stopwatchFactory, + ); + } else { + logger = StdoutLogger( + terminal: _terminal, + stdio: _stdio, + outputPreferences: _outputPreferences, + stopwatchFactory: _stopwatchFactory); + } + return logger; + } +} diff --git a/packages/flutter_migrate/lib/flutter_migrate.dart b/packages/flutter_migrate/lib/flutter_migrate.dart new file mode 100644 index 00000000000..db2bf7d3a78 --- /dev/null +++ b/packages/flutter_migrate/lib/flutter_migrate.dart @@ -0,0 +1,5 @@ +// 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. + +library flutter_migrate; diff --git a/packages/flutter_migrate/lib/src/commands/abandon.dart b/packages/flutter_migrate/lib/src/commands/abandon.dart new file mode 100644 index 00000000000..bd276a74111 --- /dev/null +++ b/packages/flutter_migrate/lib/src/commands/abandon.dart @@ -0,0 +1,140 @@ +// 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/command.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/project.dart'; +import '../base/terminal.dart'; + +import '../utils.dart'; + +/// Abandons the existing migration by deleting the migrate working directory. +class MigrateAbandonCommand extends MigrateCommand { + MigrateAbandonCommand({ + required this.logger, + required this.fileSystem, + required this.terminal, + required ProcessManager processManager, + }) : migrateUtils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + processManager: processManager, + ) { + argParser.addOption( + 'staging-directory', + help: 'Specifies the custom migration working directory used to stage ' + 'and edit proposed changes. This path can be absolute or relative ' + 'to the flutter project root. This defaults to ' + '`$kDefaultMigrateStagingDirectoryName`', + valueHelp: 'path', + ); + argParser.addOption( + 'project-directory', + help: 'The root directory of the flutter project. This defaults to the ' + 'current working directory if omitted.', + valueHelp: 'path', + ); + argParser.addFlag( + 'force', + abbr: 'f', + help: + 'Delete the migrate working directory without asking for confirmation.', + ); + argParser.addFlag( + 'flutter-subcommand', + help: + 'Enable when using the flutter tool as a subcommand. This changes the ' + 'wording of log messages to indicate the correct suggested commands to use.', + ); + } + + final Logger logger; + + final FileSystem fileSystem; + + final Terminal terminal; + + final MigrateUtils migrateUtils; + + @override + final String name = 'abandon'; + + @override + final String description = + 'Deletes the current active migration working directory.'; + + @override + Future runCommand() async { + final String? projectDirectory = stringArg('project-directory'); + final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory(); + final FlutterProject project = projectDirectory == null + ? FlutterProject.current(fileSystem) + : flutterProjectFactory + .fromDirectory(fileSystem.directory(projectDirectory)); + final bool isSubcommand = boolArg('flutter-subcommand') ?? false; + Directory stagingDirectory = + project.directory.childDirectory(kDefaultMigrateStagingDirectoryName); + final String? customStagingDirectoryPath = stringArg('staging-directory'); + if (customStagingDirectoryPath != null) { + if (fileSystem.path.isAbsolute(customStagingDirectoryPath)) { + stagingDirectory = fileSystem.directory(customStagingDirectoryPath); + } else { + stagingDirectory = + project.directory.childDirectory(customStagingDirectoryPath); + } + if (!stagingDirectory.existsSync()) { + logger.printError( + 'Provided staging directory `$customStagingDirectoryPath` ' + 'does not exist or is not valid.'); + return const CommandResult(ExitStatus.fail); + } + } + if (!stagingDirectory.existsSync()) { + logger + .printStatus('No migration in progress. Start a new migration with:'); + printCommandText('start', logger, standalone: !isSubcommand); + return const CommandResult(ExitStatus.fail); + } + + logger.printStatus('\nAbandoning the existing migration will delete the ' + 'migration staging directory at ${stagingDirectory.path}'); + final bool force = boolArg('force') ?? false; + if (!force) { + String selection = 'y'; + terminal.usesTerminalUi = true; + try { + selection = await terminal.promptForCharInput( + ['y', 'n'], + logger: logger, + prompt: + 'Are you sure you wish to continue with abandoning? (y)es, (N)o', + defaultChoiceIndex: 1, + ); + } on StateError catch (e) { + logger.printError( + e.message, + indent: 0, + ); + } + if (selection != 'y') { + return const CommandResult(ExitStatus.success); + } + } + + try { + stagingDirectory.deleteSync(recursive: true); + } on FileSystemException catch (e) { + logger.printError('Deletion failed with: $e'); + logger.printError( + 'Please manually delete the staging directory at `${stagingDirectory.path}`'); + } + + logger.printStatus('\nAbandon complete. Start a new migration with:'); + printCommandText('start', logger, standalone: !isSubcommand); + return const CommandResult(ExitStatus.success); + } +} diff --git a/packages/flutter_migrate/lib/src/commands/apply.dart b/packages/flutter_migrate/lib/src/commands/apply.dart new file mode 100644 index 00000000000..6d0556ee288 --- /dev/null +++ b/packages/flutter_migrate/lib/src/commands/apply.dart @@ -0,0 +1,219 @@ +// 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/command.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/project.dart'; +import '../base/terminal.dart'; +import '../environment.dart'; +import '../flutter_project_metadata.dart'; + +import '../manifest.dart'; +import '../update_locks.dart'; +import '../utils.dart'; + +/// Migrate subcommand that checks the migrate working directory for unresolved conflicts and +/// applies the staged changes to the project. +class MigrateApplyCommand extends MigrateCommand { + MigrateApplyCommand({ + bool verbose = false, + required this.logger, + required this.fileSystem, + required this.terminal, + required ProcessManager processManager, + }) : _verbose = verbose, + migrateUtils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + processManager: processManager, + ) { + argParser.addOption( + 'staging-directory', + help: 'Specifies the custom migration working directory used to stage ' + 'and edit proposed changes. This path can be absolute or relative ' + 'to the flutter project root. This defaults to ' + '`$kDefaultMigrateStagingDirectoryName`', + valueHelp: 'path', + ); + argParser.addOption( + 'project-directory', + help: 'The root directory of the flutter project. This defaults to the ' + 'current working directory if omitted.', + valueHelp: 'path', + ); + argParser.addFlag( + 'force', + abbr: 'f', + help: 'Ignore unresolved merge conflicts and uncommitted changes and ' + 'apply staged changes by force.', + ); + argParser.addFlag( + 'keep-working-directory', + help: 'Do not delete the working directory.', + ); + argParser.addFlag( + 'flutter-subcommand', + help: + 'Enable when using the flutter tool as a subcommand. This changes the ' + 'wording of log messages to indicate the correct suggested commands to use.', + ); + } + + final bool _verbose; + + final Logger logger; + + final FileSystem fileSystem; + + final Terminal terminal; + + final MigrateUtils migrateUtils; + + @override + final String name = 'apply'; + + @override + final String description = r'Accepts the changes produced by `$ flutter ' + 'migrate start` and copies the changed files into ' + 'your project files. All merge conflicts should ' + 'be resolved before apply will complete ' + 'successfully. If conflicts still exist, this ' + 'command will print the remaining conflicted files.'; + + @override + Future runCommand() async { + final String? projectDirectory = stringArg('project-directory'); + final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory(); + final FlutterProject project = projectDirectory == null + ? FlutterProject.current(fileSystem) + : flutterProjectFactory + .fromDirectory(fileSystem.directory(projectDirectory)); + final FlutterToolsEnvironment environment = + await FlutterToolsEnvironment.initializeFlutterToolsEnvironment(logger); + final bool isSubcommand = boolArg('flutter-subcommand') ?? false; + + if (!await gitRepoExists(project.directory.path, logger, migrateUtils)) { + logger.printStatus('No git repo found. Please run in a project with an ' + 'initialized git repo or initialize one with:'); + printCommandText('git init', logger, standalone: null); + return const CommandResult(ExitStatus.fail); + } + + final bool force = boolArg('force') ?? false; + + Directory stagingDirectory = + project.directory.childDirectory(kDefaultMigrateStagingDirectoryName); + final String? customStagingDirectoryPath = stringArg('staging-directory'); + if (customStagingDirectoryPath != null) { + if (fileSystem.path.isAbsolute(customStagingDirectoryPath)) { + stagingDirectory = fileSystem.directory(customStagingDirectoryPath); + } else { + stagingDirectory = + project.directory.childDirectory(customStagingDirectoryPath); + } + } + if (!stagingDirectory.existsSync()) { + logger.printStatus( + 'No migration in progress at $stagingDirectory. Please run:'); + printCommandText('start', logger, standalone: !isSubcommand); + return const CommandResult(ExitStatus.fail); + } + + final File manifestFile = + MigrateManifest.getManifestFileFromDirectory(stagingDirectory); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + if (!checkAndPrintMigrateStatus(manifest, stagingDirectory, + warnConflict: true, logger: logger) && + !force) { + logger.printStatus( + 'Conflicting files found. Resolve these conflicts and try again.'); + logger.printStatus('Guided conflict resolution wizard:'); + printCommandText('resolve-conflicts', logger, standalone: !isSubcommand); + return const CommandResult(ExitStatus.fail); + } + + if (await hasUncommittedChanges( + project.directory.path, logger, migrateUtils) && + !force) { + return const CommandResult(ExitStatus.fail); + } + + logger.printStatus('Applying migration.'); + // Copy files from working directory to project root + final List allFilesToCopy = []; + allFilesToCopy.addAll(manifest.mergedFiles); + allFilesToCopy.addAll(manifest.conflictFiles); + allFilesToCopy.addAll(manifest.addedFiles); + if (allFilesToCopy.isNotEmpty && _verbose) { + logger.printStatus('Modifying ${allFilesToCopy.length} files.', + indent: 2); + } + for (final String localPath in allFilesToCopy) { + if (_verbose) { + logger.printStatus('Writing $localPath'); + } + final File workingFile = stagingDirectory.childFile(localPath); + final File targetFile = project.directory.childFile(localPath); + if (!workingFile.existsSync()) { + continue; + } + + if (!targetFile.existsSync()) { + targetFile.createSync(recursive: true); + } + try { + targetFile.writeAsStringSync(workingFile.readAsStringSync(), + flush: true); + } on FileSystemException { + targetFile.writeAsBytesSync(workingFile.readAsBytesSync(), flush: true); + } + } + // Delete files slated for deletion. + if (manifest.deletedFiles.isNotEmpty) { + logger.printStatus('Deleting ${manifest.deletedFiles.length} files.', + indent: 2); + } + for (final String localPath in manifest.deletedFiles) { + final File targetFile = project.directory.childFile(localPath); + targetFile.deleteSync(); + } + + // Update the migrate config files to reflect latest migration. + if (_verbose) { + logger.printStatus('Updating .migrate_configs'); + } + final FlutterProjectMetadata metadata = FlutterProjectMetadata( + project.directory.childFile('.metadata'), logger); + + final String currentGitHash = + environment.getString('FlutterVersion.frameworkRevision') ?? ''; + metadata.migrateConfig.populate( + projectDirectory: project.directory, + currentRevision: currentGitHash, + logger: logger, + ); + + // Clean up the working directory + final bool keepWorkingDirectory = + boolArg('keep-working-directory') ?? false; + if (!keepWorkingDirectory) { + stagingDirectory.deleteSync(recursive: true); + } + + // Detect pub dependency locking. Run flutter pub upgrade --major-versions + await updatePubspecDependencies(project, migrateUtils, logger, terminal); + + // Detect gradle lockfiles in android directory. Delete lockfiles and regenerate with ./gradlew tasks (any gradle task that requires a build). + await updateGradleDependencyLocking( + project, migrateUtils, logger, terminal, _verbose, fileSystem); + + logger.printStatus('Migration complete. You may use commands like `git ' + 'status`, `git diff` and `git restore ` to continue ' + 'working with the migrated files.'); + return const CommandResult(ExitStatus.success); + } +} diff --git a/packages/flutter_migrate/lib/src/commands/resolve_conflicts.dart b/packages/flutter_migrate/lib/src/commands/resolve_conflicts.dart new file mode 100644 index 00000000000..ed7fc3c399b --- /dev/null +++ b/packages/flutter_migrate/lib/src/commands/resolve_conflicts.dart @@ -0,0 +1,400 @@ +// 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:math'; + +import '../base/command.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/project.dart'; +import '../base/terminal.dart'; + +import '../manifest.dart'; +import '../utils.dart'; + +/// Flutter migrate subcommand that guides the developer through conflicts, +/// allowing them to accept the original, the new lines, or skip and resolve manually. +class MigrateResolveConflictsCommand extends MigrateCommand { + MigrateResolveConflictsCommand({ + required this.logger, + required this.fileSystem, + required this.terminal, + }) { + argParser.addOption( + 'staging-directory', + help: + 'Specifies the custom migration staging directory used to stage and edit proposed changes. ' + 'This path can be absolute or relative to the flutter project root. This defaults to `$kDefaultMigrateStagingDirectoryName`', + valueHelp: 'path', + ); + argParser.addOption( + 'project-directory', + help: 'The root directory of the flutter project.', + valueHelp: 'path', + ); + argParser.addOption( + 'context-lines', + defaultsTo: '5', + help: + 'The number of lines of context to show around the each conflict. Defaults to 5.', + ); + argParser.addFlag( + 'confirm-commit', + defaultsTo: true, + help: + 'Indicates if proposed changes require user verification before writing to disk.', + ); + argParser.addFlag( + 'flutter-subcommand', + help: + 'Enable when using the flutter tool as a subcommand. This changes the ' + 'wording of log messages to indicate the correct suggested commands to use.', + ); + } + + final Logger logger; + + final FileSystem fileSystem; + + final Terminal terminal; + + @override + final String name = 'resolve-conflicts'; + + @override + final String description = + 'Prints the current status of the in progress migration.'; + + static const String _conflictStartMarker = '<<<<<<<'; + static const String _conflictDividerMarker = '======='; + static const String _conflictEndMarker = '>>>>>>>'; + + @override + Future runCommand() async { + final String? projectDirectory = stringArg('project-directory'); + final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory(); + final FlutterProject project = projectDirectory == null + ? FlutterProject.current(fileSystem) + : flutterProjectFactory + .fromDirectory(fileSystem.directory(projectDirectory)); + final bool isSubcommand = boolArg('flutter-subcommand') ?? false; + + Directory stagingDirectory = + project.directory.childDirectory(kDefaultMigrateStagingDirectoryName); + final String? customStagingDirectoryPath = stringArg('staging-directory'); + if (customStagingDirectoryPath != null) { + if (fileSystem.path.isAbsolute(customStagingDirectoryPath)) { + stagingDirectory = fileSystem.directory(customStagingDirectoryPath); + } else { + stagingDirectory = + project.directory.childDirectory(customStagingDirectoryPath); + } + } + if (!stagingDirectory.existsSync()) { + logger + .printStatus('No migration in progress. Start a new migration with:'); + printCommandText('start', logger, standalone: !isSubcommand); + return const CommandResult(ExitStatus.fail); + } + + final File manifestFile = + MigrateManifest.getManifestFileFromDirectory(stagingDirectory); + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + + checkAndPrintMigrateStatus(manifest, stagingDirectory, logger: logger); + + final List conflictFiles = + manifest.remainingConflictFiles(stagingDirectory); + + terminal.usesTerminalUi = true; + + for (int i = 0; i < conflictFiles.length; i++) { + final String localPath = conflictFiles[i]; + final File file = stagingDirectory.childFile(localPath); + final List lines = file.readAsStringSync().split('\n'); + // We write a newline in the output, this counteracts it. + if (lines.last == '') { + lines.removeLast(); + } + + // Find all conflicts + final List conflicts = findConflicts(lines, localPath); + + // Prompt developer + final CommandResult? promptResult = + await promptDeveloperSelectAction(conflicts, lines, localPath); + if (promptResult != null) { + return promptResult; + } + + final bool result = + await verifyAndCommit(conflicts, lines, file, localPath); + if (!result) { + i--; + } + } + return const CommandResult(ExitStatus.success); + } + + /// Parses the lines of a file and extracts a list of Conflicts. + List findConflicts(List lines, String localPath) { + // Find all conflicts + final List conflicts = []; + Conflict currentConflict = Conflict.empty(); + for (int lineNumber = 0; lineNumber < lines.length; lineNumber++) { + final String line = lines[lineNumber]; + if (line.contains(_conflictStartMarker)) { + currentConflict.startLine = lineNumber; + } else if (line.contains(_conflictDividerMarker)) { + currentConflict.dividerLine = lineNumber; + } else if (line.contains(_conflictEndMarker)) { + currentConflict.endLine = lineNumber; + if (!(currentConflict.startLine == null || + currentConflict.dividerLine == null || + currentConflict.startLine! < currentConflict.dividerLine!) || + !(currentConflict.dividerLine == null || + currentConflict.endLine == null || + currentConflict.dividerLine! < currentConflict.endLine!)) { + throw StateError( + 'Invalid merge conflict detected in $localPath: Improperly ordered conflict markers.'); + } + conflicts.add(currentConflict); + currentConflict = Conflict.empty(); + } + } + return conflicts; + } + + /// Display a detected conflict and prompt the developer on whether to accept the original lines, new lines, + /// or skip handling the conflict. + Future promptDeveloperSelectAction( + List conflicts, List lines, String localPath) async { + final int contextLineCount = int.parse(stringArg('context-lines')!); + for (final Conflict conflict in conflicts) { + if (!conflict.isValid) { + conflict.selection = ConflictSelection.skip; + continue; + } + // Print the conflict for reference + logger.printStatus(terminal.clearScreen(), newline: false); + logger.printStatus('Cyan', color: TerminalColor.cyan, newline: false); + logger.printStatus(' = Original lines. ', newline: false); + logger.printStatus('Green', color: TerminalColor.green, newline: false); + logger.printStatus(' = New lines.\n', newline: true); + + // Print the conflict for reference + for (int lineNumber = max(conflict.startLine! - contextLineCount, 0); + lineNumber < conflict.startLine!; + lineNumber++) { + printConflictLine(lines[lineNumber], lineNumber, + color: TerminalColor.grey); + } + printConflictLine(lines[conflict.startLine!], conflict.startLine!); + for (int lineNumber = conflict.startLine! + 1; + lineNumber < conflict.dividerLine!; + lineNumber++) { + printConflictLine(lines[lineNumber], lineNumber, + color: TerminalColor.cyan); + } + printConflictLine(lines[conflict.dividerLine!], conflict.dividerLine!); + for (int lineNumber = conflict.dividerLine! + 1; + lineNumber < conflict.endLine!; + lineNumber++) { + printConflictLine(lines[lineNumber], lineNumber, + color: TerminalColor.green); + } + printConflictLine(lines[conflict.endLine!], conflict.endLine!); + for (int lineNumber = conflict.endLine! + 1; + lineNumber <= + (conflict.endLine! + contextLineCount).clamp(0, lines.length - 1); + lineNumber++) { + printConflictLine(lines[lineNumber], lineNumber, + color: TerminalColor.grey); + } + + logger.printStatus('\nConflict in $localPath.'); + // Select action + String selection = 's'; + selection = await terminal.promptForCharInput( + ['o', 'n', 's', 'q'], + logger: logger, + prompt: + 'Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit.', + defaultChoiceIndex: 2, + ); + + switch (selection) { + case 'o': + { + conflict.chooseOriginal(); + break; + } + case 'n': + { + conflict.chooseNew(); + break; + } + case 's': + { + conflict.chooseSkip(); + break; + } + case 'q': + { + logger.printStatus( + 'Exiting wizard. You may continue where you left off by re-running the command.', + newline: true); + return const CommandResult(ExitStatus.success); + } + } + } + return null; + } + + /// Prints a summary of the changes selected and prompts the developer to commit, abandon, or retry + /// the changes. + /// + /// Returns true if changes were accepted or rejected. Returns false if user indicated to retry. + Future verifyAndCommit(List conflicts, List lines, + File file, String localPath) async { + int originalCount = 0; + int newCount = 0; + int skipCount = 0; + + String result = ''; + int lastPrintedLine = 0; + bool hasChanges = + false; // don't unecessarily write file if no changes were made. + for (final Conflict conflict in conflicts) { + if (!conflict.isValid) { + continue; + } + if (conflict.selection != ConflictSelection.skip) { + hasChanges = true; // only skip results in no changes + } + for (int lineNumber = lastPrintedLine; + lineNumber < conflict.startLine!; + lineNumber++) { + result += '${lines[lineNumber]}\n'; + } + switch (conflict.selection) { + case ConflictSelection.skip: + // Skipped this conflict. Add all lines. + for (int lineNumber = conflict.startLine!; + lineNumber <= conflict.endLine!; + lineNumber++) { + result += '${lines[lineNumber]}\n'; + } + skipCount++; + break; + case ConflictSelection.keepOriginal: + // Keeping original lines + for (int lineNumber = conflict.startLine! + 1; + lineNumber < conflict.dividerLine!; + lineNumber++) { + result += '${lines[lineNumber]}\n'; + } + originalCount++; + break; + case ConflictSelection.keepNew: + // Keeping new lines + for (int lineNumber = conflict.dividerLine! + 1; + lineNumber < conflict.endLine!; + lineNumber++) { + result += '${lines[lineNumber]}\n'; + } + newCount++; + break; + } + lastPrintedLine = (conflict.endLine! + 1).clamp(0, lines.length); + } + for (int lineNumber = lastPrintedLine; + lineNumber < lines.length; + lineNumber++) { + result += '${lines[lineNumber]}\n'; + } + + // Display conflict summary for this file and confirm with user if the changes should be commited. + final bool confirm = boolArg('confirm-commit') ?? true; + if (confirm && skipCount != conflicts.length) { + logger.printStatus(terminal.clearScreen(), newline: false); + logger.printStatus('Conflicts in $localPath complete.\n'); + logger.printStatus( + 'You chose to:\n Skip $skipCount conflicts\n Acccept the original lines for $originalCount conflicts\n Accept the new lines for $newCount conflicts\n'); + String selection = 'n'; + selection = await terminal.promptForCharInput( + ['y', 'n', 'r'], + logger: logger, + prompt: + 'Commit the changes to the working directory? (y)es, (n)o, (r)etry this file', + defaultChoiceIndex: 1, + ); + switch (selection) { + case 'y': + { + if (hasChanges) { + file.writeAsStringSync(result, flush: true); + } + break; + } + case 'n': + { + break; + } + case 'r': + { + return false; + } + } + } else { + file.writeAsStringSync(result, flush: true); + } + return true; + } + + /// Prints the line of a file with a prefix that indicates the line count. + void printConflictLine(String text, int lineNumber, + {TerminalColor? color, int paddingLength = 5}) { + // Default padding of 5 pads line numbers up to 99,999 + final String padding = ' ' * (paddingLength - lineNumber.toString().length); + logger.printStatus('$lineNumber$padding', + color: TerminalColor.grey, newline: false, indent: 2); + logger.printStatus(text, color: color); + } +} + +enum ConflictSelection { + keepOriginal, + keepNew, + skip, +} + +/// Simple data class that represents a conflict in a file and tracks what the developer chose to do with it. +class Conflict { + Conflict(this.startLine, this.dividerLine, this.endLine) + : selection = ConflictSelection.skip; + + Conflict.empty() : selection = ConflictSelection.skip; + + int? startLine; + int? dividerLine; + int? endLine; + + ConflictSelection selection; + + bool get isValid => + startLine != null && dividerLine != null && endLine != null; + + void chooseOriginal() { + selection = ConflictSelection.keepOriginal; + } + + void chooseSkip() { + selection = ConflictSelection.skip; + } + + void chooseNew() { + selection = ConflictSelection.keepNew; + } +} diff --git a/packages/flutter_migrate/lib/src/commands/start.dart b/packages/flutter_migrate/lib/src/commands/start.dart new file mode 100644 index 00000000000..c4ae29165b8 --- /dev/null +++ b/packages/flutter_migrate/lib/src/commands/start.dart @@ -0,0 +1,344 @@ +// 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/command.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/project.dart'; + +import '../compute.dart'; +import '../environment.dart'; +import '../manifest.dart'; +import '../result.dart'; +import '../utils.dart'; + +class MigrateStartCommand extends MigrateCommand { + MigrateStartCommand({ + bool verbose = false, + required this.logger, + required this.fileSystem, + required ProcessManager processManager, + }) : _verbose = verbose, + migrateUtils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + processManager: processManager, + ) { + argParser.addOption( + 'staging-directory', + help: + 'Specifies the custom migration staging directory used to stage and edit proposed changes. ' + 'This path can be absolute or relative to the flutter project root.', + valueHelp: 'path', + ); + argParser.addOption( + 'project-directory', + help: 'The root directory of the flutter project.', + valueHelp: 'path', + ); + argParser.addOption( + 'platforms', + help: + 'Restrict the tool to only migrating the listed platforms. By default all platforms generated by ' + 'flutter create will be migrated. To indicate the project root, use the `root` platform', + valueHelp: 'root,android,ios,windows...', + ); + argParser.addFlag( + 'delete-temp-directories', + help: + 'Indicates if the temporary directories created by the migrate tool be deleted.', + ); + argParser.addOption( + 'base-app-directory', + help: + 'The directory containing the base reference app. This is used as the common ancestor in a 3 way merge. ' + 'Providing this directory will prevent the tool from generating its own. This is primarily used ' + 'in testing and CI.', + valueHelp: 'path', + ); + argParser.addOption( + 'target-app-directory', + help: + 'The directory containing the target reference app. This is used as the target app in 3 way merge. ' + 'Providing this directory will prevent the tool from generating its own. This is primarily used ' + 'in testing and CI.', + valueHelp: 'path', + ); + argParser.addFlag( + 'allow-fallback-base-revision', + help: + 'If a base revision cannot be determined, this flag enables using flutter 1.0.0 as a fallback base revision. ' + 'Using this fallback will typically produce worse quality migrations and possibly more conflicts.', + ); + argParser.addOption( + 'base-revision', + help: + 'Manually sets the base revision to generate the base ancestor reference app with. This may be used ' + 'if the tool is unable to determine an appropriate base revision.', + valueHelp: 'git revision hash', + ); + argParser.addOption( + 'target-revision', + help: + 'Manually sets the target revision to generate the target reference app with. Passing this indicates ' + 'that the current flutter sdk version is not the version that should be migrated to.', + valueHelp: 'git revision hash', + ); + argParser.addFlag( + 'prefer-two-way-merge', + negatable: false, + help: + 'Avoid three way merges when possible. Enabling this effectively ignores the base ancestor reference ' + 'files when a merge is required, opting for a simpler two way merge instead. In some edge cases typically ' + 'involving using a fallback or incorrect base revision, the default three way merge algorithm may produce ' + 'incorrect merges. Two way merges are more conflict prone, but less likely to produce incorrect results ' + 'silently.', + ); + argParser.addFlag( + 'flutter-subcommand', + help: + 'Enable when using the flutter tool as a subcommand. This changes the ' + 'wording of log messages to indicate the correct suggested commands to use.', + ); + } + + final bool _verbose; + + final Logger logger; + + final FileSystem fileSystem; + + final MigrateUtils migrateUtils; + + @override + final String name = 'start'; + + @override + final String description = + r'Begins a new migration. Computes the changes needed to migrate the project from the base revision of Flutter to the current revision of Flutter and outputs the results in a working directory. Use `$ flutter migrate apply` accept and apply the changes.'; + + @override + Future runCommand() async { + final FlutterToolsEnvironment environment = + await FlutterToolsEnvironment.initializeFlutterToolsEnvironment(logger); + if (!_validateEnvironment(environment)) { + return const CommandResult(ExitStatus.fail); + } + final String? projectRootDirPath = stringArg('project-directory') ?? + environment.getString('FlutterProject.directory'); + final Directory projectRootDir = fileSystem.directory(projectRootDirPath); + final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory(); + final FlutterProject project = projectRootDirPath == null + ? FlutterProject.current(fileSystem) + : flutterProjectFactory + .fromDirectory(fileSystem.directory(projectRootDirPath)); + + final bool isModule = + environment.getBool('FlutterProject.isModule') ?? false; + final bool isPlugin = + environment.getBool('FlutterProject.isPlugin') ?? false; + if (isModule || isPlugin) { + logger.printError( + 'Migrate tool only supports app projects. This project is a ${isModule ? 'module' : 'plugin'}'); + return const CommandResult(ExitStatus.fail); + } + final bool isSubcommand = boolArg('flutter-subcommand') ?? false; + + if (!await gitRepoExists(project.directory.path, logger, migrateUtils)) { + return const CommandResult(ExitStatus.fail); + } + + Directory stagingDirectory = + project.directory.childDirectory(kDefaultMigrateStagingDirectoryName); + final String? customStagingDirectoryPath = stringArg('staging-directory'); + if (customStagingDirectoryPath != null) { + if (fileSystem.path.isAbsolute(customStagingDirectoryPath)) { + stagingDirectory = fileSystem.directory(customStagingDirectoryPath); + } else { + stagingDirectory = + project.directory.childDirectory(customStagingDirectoryPath); + } + } + if (stagingDirectory.existsSync()) { + logger.printStatus('Old migration already in progress.', emphasis: true); + logger.printStatus( + 'Pending migration files exist in `${stagingDirectory.path}/$kDefaultMigrateStagingDirectoryName`'); + logger.printStatus( + 'Resolve merge conflicts and accept changes with by running:'); + printCommandText('apply', logger, standalone: !isSubcommand); + logger.printStatus( + 'You may also abandon the existing migration and start a new one with:'); + printCommandText('abandon', logger, standalone: !isSubcommand); + return const CommandResult(ExitStatus.fail); + } + + if (await hasUncommittedChanges( + project.directory.path, logger, migrateUtils)) { + return const CommandResult(ExitStatus.fail); + } + + List? platforms; + if (stringArg('platforms') != null) { + platforms = []; + for (String platformString in stringArg('platforms')!.split(',')) { + platformString = platformString.trim(); + platforms.add(SupportedPlatform.values.firstWhere( + (SupportedPlatform val) => + val.toString() == 'SupportedPlatform.$platformString')); + } + } + + // final MigrateResult? migrateResult = null; + final MigrateResult? migrateResult = await computeMigration( + verbose: _verbose, + flutterProject: project, + baseAppPath: stringArg('base-app-directory'), + targetAppPath: stringArg('target-app-directory'), + baseRevision: stringArg('base-revision'), + targetRevision: stringArg('target-revision'), + deleteTempDirectories: boolArg('delete-temp-directories') ?? true, + platforms: platforms, + preferTwoWayMerge: boolArg('prefer-two-way-merge') ?? false, + allowFallbackBaseRevision: + boolArg('allow-fallback-base-revision') ?? false, + fileSystem: fileSystem, + logger: logger, + migrateUtils: migrateUtils, + environment: environment, + ); + if (migrateResult == null) { + return const CommandResult(ExitStatus.fail); + } + + _deleteTempDirectories( + paths: [], + directories: migrateResult.tempDirectories, + ); + + await writeStagingDir(migrateResult, logger, + verbose: _verbose, projectRootDir: projectRootDir); + + logger.printStatus( + 'The migrate tool has staged proposed changes in the migrate staging directory.\n'); + logger.printStatus('Guided conflict resolution wizard:'); + printCommandText('resolve-conflicts', logger, standalone: !isSubcommand); + logger.printStatus('Check the status and diffs of the migration with:'); + printCommandText('status', logger, standalone: !isSubcommand); + logger.printStatus('Abandon the proposed migration with:'); + printCommandText('abandon', logger, standalone: !isSubcommand); + logger.printStatus( + 'Accept staged changes after resolving any merge conflicts with:'); + printCommandText('apply', logger, standalone: !isSubcommand); + + return const CommandResult(ExitStatus.success); + } + + /// Deletes the files or directories at the provided paths. + void _deleteTempDirectories( + {List paths = const [], + List directories = const []}) { + for (final Directory d in directories) { + try { + d.deleteSync(recursive: true); + } on FileSystemException catch (e) { + logger.printError( + 'Unabled to delete ${d.path} due to ${e.message}, please clean up manually.'); + } + } + for (final String p in paths) { + try { + fileSystem.directory(p).deleteSync(recursive: true); + } on FileSystemException catch (e) { + logger.printError( + 'Unabled to delete $p due to ${e.message}, please clean up manually.'); + } + } + } + + bool _validateEnvironment(FlutterToolsEnvironment environment) { + if (environment.getString('FlutterProject.directory') == null) { + logger.printError( + 'No valid flutter project found. This command must be run from a flutter project directory'); + return false; + } + if (environment.getString('FlutterProject.manifest.appname') == null) { + logger.printError('No app name found in project pubspec.yaml'); + return false; + } + if (!(environment.getBool('FlutterProject.android.exists') ?? false) && + environment['FlutterProject.android.isKotlin'] == null) { + logger.printError( + 'Could not detect if android project uses kotlin or java'); + return false; + } + if (!(environment.getBool('FlutterProject.ios.exists') ?? false) && + environment['FlutterProject.ios.isSwift'] == null) { + logger.printError( + 'Could not detect if iosProject uses swift or objective-c'); + return false; + } + return true; + } + + /// Writes the files into the working directory for the developer to review and resolve any conflicts. + Future writeStagingDir(MigrateResult migrateResult, Logger logger, + {bool verbose = false, required Directory projectRootDir}) async { + final Directory stagingDir = + projectRootDir.childDirectory(kDefaultMigrateStagingDirectoryName); + if (verbose) { + logger.printStatus( + 'Writing migrate staging directory at `${stagingDir.path}`'); + } + print('1'); + // Write files in working dir + for (final MergeResult result in migrateResult.mergeResults) { + final File file = stagingDir.childFile(result.localPath); + file.createSync(recursive: true); + if (result is StringMergeResult) { + file.writeAsStringSync(result.mergedString, flush: true); + } else { + file.writeAsBytesSync((result as BinaryMergeResult).mergedBytes, + flush: true); + } + } + print('2 ${migrateResult.addedFiles.length}'); + for (final FilePendingMigration addedFile in migrateResult.addedFiles) { + print('2 ${addedFile.file}'); + } + // Write all files that are newly added in target + for (final FilePendingMigration addedFile in migrateResult.addedFiles) { + print(' 2.1 ${addedFile.localPath}'); + print(' 2.1 ${stagingDir.path}'); + print(' 2.1 ${stagingDir.existsSync()}'); + final File file = stagingDir.childFile(addedFile.localPath); + file.createSync(recursive: true); + try { + print(' 2.2 string'); + file.writeAsStringSync(addedFile.file.readAsStringSync(), flush: true); + // } on FileSystemException { + } catch (e) { + print(e.toString()); + print(' 2.2 bytes'); + file.writeAsBytesSync(addedFile.file.readAsBytesSync(), flush: true); + } + } + print('3'); + + // Write the MigrateManifest. + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: migrateResult, + ); + manifest.writeFile(); + print('4'); + + // output the manifest contents. + checkAndPrintMigrateStatus(manifest, stagingDir, logger: logger); + + print('PRINTING BOX'); + logger.printBox('Staging directory created at `${stagingDir.path}`'); + } +} diff --git a/packages/flutter_migrate/lib/src/commands/status.dart b/packages/flutter_migrate/lib/src/commands/status.dart new file mode 100644 index 00000000000..dc4501757c0 --- /dev/null +++ b/packages/flutter_migrate/lib/src/commands/status.dart @@ -0,0 +1,190 @@ +// 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/command.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/project.dart'; +import '../base/terminal.dart'; + +import '../manifest.dart'; +import '../utils.dart'; + +/// Flutter migrate subcommand to check the migration status of the project. +class MigrateStatusCommand extends MigrateCommand { + MigrateStatusCommand({ + bool verbose = false, + required this.logger, + required this.fileSystem, + required ProcessManager processManager, + }) : _verbose = verbose, + migrateUtils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + processManager: processManager, + ) { + argParser.addOption( + 'staging-directory', + help: 'Specifies the custom migration working directory used to stage ' + 'and edit proposed changes. This path can be absolute or relative ' + 'to the flutter project root. This defaults to ' + '`$kDefaultMigrateStagingDirectoryName`', + valueHelp: 'path', + ); + argParser.addOption( + 'project-directory', + help: 'The root directory of the flutter project. This defaults to the ' + 'current working directory if omitted.', + valueHelp: 'path', + ); + argParser.addFlag( + 'diff', + defaultsTo: true, + help: 'Shows the diff output when enabled. Enabled by default.', + ); + argParser.addFlag( + 'show-added-files', + help: 'Shows the contents of added files. Disabled by default.', + ); + argParser.addFlag( + 'flutter-subcommand', + help: + 'Enable when using the flutter tool as a subcommand. This changes the ' + 'wording of log messages to indicate the correct suggested commands to use.', + ); + } + + final bool _verbose; + + final Logger logger; + + final FileSystem fileSystem; + + final MigrateUtils migrateUtils; + + @override + final String name = 'status'; + + @override + final String description = + 'Prints the current status of the in progress migration.'; + + /// Manually marks the lines in a diff that should be printed unformatted for visbility. + /// + /// This is used to ensure the initial lines that display the files being diffed and the + /// git revisions are printed and never skipped. + final Set _initialDiffLines = {0, 1}; + + @override + Future runCommand() async { + final String? projectDirectory = stringArg('project-directory'); + final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory(); + final FlutterProject project = projectDirectory == null + ? FlutterProject.current(fileSystem) + : flutterProjectFactory + .fromDirectory(fileSystem.directory(projectDirectory)); + final bool isSubcommand = boolArg('flutter-subcommand') ?? false; + + Directory stagingDirectory = + project.directory.childDirectory(kDefaultMigrateStagingDirectoryName); + final String? customStagingDirectoryPath = stringArg('staging-directory'); + if (customStagingDirectoryPath != null) { + if (fileSystem.path.isAbsolute(customStagingDirectoryPath)) { + stagingDirectory = fileSystem.directory(customStagingDirectoryPath); + } else { + stagingDirectory = + project.directory.childDirectory(customStagingDirectoryPath); + } + } + if (!stagingDirectory.existsSync()) { + logger.printStatus( + 'No migration in progress in $stagingDirectory. Start a new migration with:'); + printCommandText('start', logger, standalone: !isSubcommand); + return const CommandResult(ExitStatus.fail); + } + + final File manifestFile = + MigrateManifest.getManifestFileFromDirectory(stagingDirectory); + if (!manifestFile.existsSync()) { + logger.printError('No migrate manifest in the migrate working directory ' + 'at ${stagingDirectory.path}. Fix the working directory ' + 'or abandon and restart the migration.'); + return const CommandResult(ExitStatus.fail); + } + final MigrateManifest manifest = MigrateManifest.fromFile(manifestFile); + + final bool showDiff = boolArg('diff') ?? true; + final bool showAddedFiles = boolArg('show-added-files') ?? true; + if (showDiff || _verbose) { + if (showAddedFiles || _verbose) { + for (final String localPath in manifest.addedFiles) { + logger.printStatus('Newly added file at $localPath:\n'); + try { + logger.printStatus( + stagingDirectory.childFile(localPath).readAsStringSync(), + color: TerminalColor.green); + } on FileSystemException { + logger.printStatus('Contents are byte data\n', + color: TerminalColor.grey); + } + } + } + final List files = []; + files.addAll(manifest.mergedFiles); + files.addAll(manifest.resolvedConflictFiles(stagingDirectory)); + files.addAll(manifest.remainingConflictFiles(stagingDirectory)); + for (final String localPath in files) { + final DiffResult result = await migrateUtils.diffFiles( + project.directory.childFile(localPath), + stagingDirectory.childFile(localPath)); + if (result.diff != '' && result.diff != null) { + // Print with different colors for better visibility. + int lineNumber = -1; + for (final String line in result.diff!.split('\n')) { + lineNumber++; + if (line.startsWith('---') || + line.startsWith('+++') || + line.startsWith('&&') || + _initialDiffLines.contains(lineNumber)) { + logger.printStatus(line); + continue; + } + if (line.startsWith('-')) { + logger.printStatus(line, color: TerminalColor.red); + continue; + } + if (line.startsWith('+')) { + logger.printStatus(line, color: TerminalColor.green); + continue; + } + logger.printStatus(line, color: TerminalColor.grey); + } + } + } + } + + logger.printBox('Working directory at `${stagingDirectory.path}`'); + + checkAndPrintMigrateStatus(manifest, stagingDirectory, logger: logger); + + final bool readyToApply = + manifest.remainingConflictFiles(stagingDirectory).isEmpty; + + if (!readyToApply) { + logger.printStatus('Guided conflict resolution wizard:'); + printCommandText('resolve-conflicts', logger, standalone: !isSubcommand); + logger.printStatus('Resolve conflicts and accept changes with:'); + } else { + logger.printStatus( + 'All conflicts resolved. Review changes above and ' + 'apply the migration with:', + color: TerminalColor.green); + } + printCommandText('apply', logger, standalone: !isSubcommand); + + return const CommandResult(ExitStatus.success); + } +} diff --git a/packages/flutter_migrate/lib/src/compute.dart b/packages/flutter_migrate/lib/src/compute.dart new file mode 100644 index 00000000000..096af3d7bbf --- /dev/null +++ b/packages/flutter_migrate/lib/src/compute.dart @@ -0,0 +1,1014 @@ +// 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/common.dart'; +import 'base/file_system.dart'; +import 'base/logger.dart'; +import 'base/project.dart'; +import 'base/terminal.dart'; +import 'custom_merge.dart'; +import 'environment.dart'; +import 'flutter_project_metadata.dart'; +import 'result.dart'; +import 'utils.dart'; + +// This defines files and directories that should be skipped regardless +// of gitignore and config settings. Using `/` as a stand-in for path separator. +const List _skippedFiles = [ + '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 _skippedDirectories = [ + '.dart_tool', // ignore the .dart_tool generated dir + '.git', // ignore the git metadata + 'lib', // Files here are always user owned and we don't want to overwrite their apps. + 'test', // Files here are typically user owned and flutter-side changes are not relevant. + 'assets', // Common directory for user assets. + 'build' // Build artifacts +]; + +bool _skipped(String localPath, FileSystem fileSystem, + {Set? blacklistPrefixes}) { + for (final String path in _skippedFiles) { + if (path.replaceAll('/', fileSystem.path.separator) == localPath) { + return true; + } + } + if (_skippedFiles.contains(localPath)) { + return true; + } + for (final String dir in _skippedDirectories) { + if (localPath.startsWith( + '${dir.replaceAll('/', fileSystem.path.separator)}${fileSystem.path.separator}')) { + return true; + } + } + if (blacklistPrefixes != null) { + for (final String prefix in blacklistPrefixes) { + if (localPath.startsWith('$prefix${fileSystem.path.separator}')) { + return true; + } + } + } + return false; +} + +const List _skippedMergeFileExt = [ + // Don't merge image files + '.png', + '.jpg', + '.jpeg', + '.gif', + '.bmp', + '.svg', + // Don't merge compiled artifacts and executables + '.jar', + '.so', + '.exe', +]; + +const Set _alwaysMigrateFiles = { + '.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', +}; + +/// True for files that should not be merged. Typically, images and binary files. +bool _skippedMerge(String localPath) { + for (final String ext in _skippedMergeFileExt) { + if (localPath.endsWith(ext) && !_alwaysMigrateFiles.contains(localPath)) { + return true; + } + } + return false; +} + +/// Data class holds the common context that is used throughout the steps of a migrate computation. +class MigrateContext { + MigrateContext({ + required this.migrateResult, + required this.flutterProject, + required this.blacklistPrefixes, + required this.logger, + required this.verbose, + required this.fileSystem, + required this.status, + required this.migrateUtils, + this.baseProject, + this.targetProject, + }); + + MigrateResult migrateResult; + FlutterProject flutterProject; + Set blacklistPrefixes; + Logger logger; + bool verbose; + FileSystem fileSystem; + Status status; + MigrateUtils migrateUtils; + + MigrateBaseFlutterProject? baseProject; + MigrateTargetFlutterProject? targetProject; +} + +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'; + case SupportedPlatform.root: + return 'root'; + } +} + +/// Computes the changes that migrates the current flutter project to the target revision. +/// +/// This is the entry point to the core migration computations. +/// +/// 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 computeMigration({ + bool verbose = false, + FlutterProject? flutterProject, + String? baseAppPath, + String? targetAppPath, + String? baseRevision, + String? targetRevision, + bool deleteTempDirectories = true, + List? platforms, + bool preferTwoWayMerge = false, + bool allowFallbackBaseRevision = false, + required FileSystem fileSystem, + required Logger logger, + required MigrateUtils migrateUtils, + required FlutterToolsEnvironment environment, +}) async { + flutterProject ??= FlutterProject.current(fileSystem); + + logger.printStatus( + 'Computing migration - this command may take a while to complete.'); + // We keep a spinner going and print periodic progress messages + // to assure the developer that the command is still working due to + // the long expected runtime. + final Status status = logger.startSpinner(); + status.pause(); + logger.printStatus('Obtaining revisions.', + indent: 2, color: TerminalColor.grey); + status.resume(); + + // Find the path prefixes to ignore. This allows subdirectories of platforms + // not part of the migration to be skipped. + platforms ??= flutterProject.getSupportedPlatforms(includeRoot: true); + final Set blacklistPrefixes = {}; + for (final SupportedPlatform platform in SupportedPlatform.values) { + blacklistPrefixes.add(platformToSubdirectoryPrefix(platform)); + } + for (final SupportedPlatform platform in platforms) { + blacklistPrefixes.remove(platformToSubdirectoryPrefix(platform)); + } + blacklistPrefixes.remove('root'); + blacklistPrefixes.remove(null); + + final MigrateContext context = MigrateContext( + migrateResult: MigrateResult.empty(), + flutterProject: flutterProject, + blacklistPrefixes: blacklistPrefixes, + logger: logger, + verbose: verbose, + fileSystem: fileSystem, + status: status, + migrateUtils: migrateUtils, + ); + + final MigrateRevisions revisionConfig = MigrateRevisions( + context: context, + baseRevision: baseRevision, + allowFallbackBaseRevision: allowFallbackBaseRevision, + platforms: platforms, + environment: environment, + ); + + // Extract the files/paths that should be ignored by the migrate tool. + // These paths are absolute paths. + if (verbose) { + logger.printStatus('Parsing unmanagedFiles.'); + } + final List unmanagedFiles = []; + final List unmanagedDirectories = []; + 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)); + } + } + status.pause(); + logger.printStatus('Generating base reference app', + indent: 2, color: TerminalColor.grey); + status.resume(); + + // Generate the base templates + final bool customBaseProjectDir = baseAppPath != null; + final bool customTargetProjectDir = targetAppPath != null; + Directory? baseProjectDir; + Directory? targetProjectDir; + if (customBaseProjectDir) { + baseProjectDir = fileSystem.directory(baseAppPath); + } else { + baseProjectDir = + fileSystem.systemTempDirectory.createTempSync('baseProject'); + if (verbose) { + logger.printStatus('Created temporary directory: ${baseProjectDir.path}', + indent: 2, color: TerminalColor.grey); + } + } + if (customTargetProjectDir) { + targetProjectDir = fileSystem.directory(targetAppPath); + } else { + targetProjectDir = + fileSystem.systemTempDirectory.createTempSync('targetProject'); + if (verbose) { + logger.printStatus( + 'Created temporary directory: ${targetProjectDir.path}', + indent: 2, + color: TerminalColor.grey); + } + } + context.migrateResult.generatedBaseTemplateDirectory = baseProjectDir; + context.migrateResult.generatedTargetTemplateDirectory = targetProjectDir; + + await migrateUtils.gitInit( + context.migrateResult.generatedBaseTemplateDirectory!.absolute.path); + await migrateUtils.gitInit( + context.migrateResult.generatedTargetTemplateDirectory!.absolute.path); + + final String name = environment['FlutterProject.manifest.appname']! as String; + final String androidLanguage = + environment['FlutterProject.android.isKotlin']! as bool + ? 'kotlin' + : 'java'; + final String iosLanguage = + environment['FlutterProject.ios.isSwift']! as bool ? 'swift' : 'objc'; + + final Directory targetFlutterDirectory = + fileSystem.directory(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: baseAppPath, + directory: baseProjectDir, + name: name, + androidLanguage: androidLanguage, + iosLanguage: iosLanguage, + platformWhitelist: platforms, + ); + context.baseProject = baseProject; + await baseProject.createProject( + context, + revisionConfig.revisionsList, + revisionConfig.revisionToConfigs, + baseRevision ?? + revisionConfig.metadataRevision ?? + _getFallbackBaseRevision( + allowFallbackBaseRevision, verbose, logger, status), + 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: targetAppPath, + directory: targetProjectDir, + name: name, + androidLanguage: androidLanguage, + iosLanguage: iosLanguage, + platformWhitelist: platforms, + ); + context.targetProject = targetProject; + await targetProject.createProject( + context, + revisionConfig.targetRevision, + targetFlutterDirectory, + ); + + await migrateUtils.gitInit(flutterProject.directory.absolute.path); + + // 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. + status.pause(); + logger.printStatus('Diffing base and target reference app.', + indent: 2, color: TerminalColor.grey); + status.resume(); + + context.migrateResult.diffMap + .addAll(await baseProject.diff(context, targetProject)); + + // Check for any new files that were added in the target reference app that did not + // exist in the base reference app. + status.pause(); + logger.printStatus('Finding newly added files', + indent: 2, color: TerminalColor.grey); + status.resume(); + context.migrateResult.addedFiles + .addAll(await baseProject.newlyAddedFiles(context, 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. + status.pause(); + logger.printStatus('Merging changes with existing project.', + indent: 2, color: TerminalColor.grey); + status.resume(); + await MigrateFlutterProject.merge( + context, + baseProject, + targetProject, + unmanagedFiles, + unmanagedDirectories, + preferTwoWayMerge, + ); + + // Clean up any temp directories generated by this tool. + status.pause(); + logger.printStatus('Cleaning up temp directories.', + indent: 2, color: TerminalColor.grey); + status.resume(); + if (deleteTempDirectories) { + // Don't delete user-provided directories + if (!customBaseProjectDir) { + context.migrateResult.tempDirectories + .add(context.migrateResult.generatedBaseTemplateDirectory!); + } + if (!customTargetProjectDir) { + context.migrateResult.tempDirectories + .add(context.migrateResult.generatedTargetTemplateDirectory!); + } + context.migrateResult.tempDirectories + .addAll(context.migrateResult.sdkDirs.values); + } + status.stop(); + return context.migrateResult; +} + +/// Returns a base revision to fallback to in case a true base revision is unknown. +String _getFallbackBaseRevision(bool allowFallbackBaseRevision, bool verbose, + Logger logger, Status status) { + if (!allowFallbackBaseRevision) { + status.stop(); + logger.printError( + 'Could not determine base revision this app was created with:'); + logger.printError( + '.metadata file did not exist or did not contain a valid revision.', + indent: 2); + logger.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=`', + 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. + if (verbose) { + status.pause(); + logger.printStatus( + 'Could not determine base revision, falling back on `v1.0.0`, revision 5391447fae6209bb21a89e6a5a6583cac1af9b4b', + color: TerminalColor.grey, + indent: 4); + status.resume(); + } + return '5391447fae6209bb21a89e6a5a6583cac1af9b4b'; +} + +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? platformWhitelist; + + /// Run git diff over each matching pair of files in the this project and the provided target project. + Future> diff( + MigrateContext context, + MigrateFlutterProject other, + ) async { + final Map diffMap = {}; + final List 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, + blacklistPrefixes: context.blacklistPrefixes)) { + 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 (context.verbose && diff.diff != '') { + context.status.pause(); + context.logger.printStatus( + 'Found ${diff.exitCode} changes in $localPath', + indent: 4, + color: TerminalColor.grey); + context.status.resume(); + 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); + } + } + if (context.verbose) { + context.status.pause(); + context.logger.printStatus( + '$modifiedFilesCount files were modified between base and target apps.'); + context.status.resume(); + } + return diffMap; + } + + /// Find all files that exist in the target reference app but not in the base reference app. + Future> newlyAddedFiles( + MigrateContext context, MigrateFlutterProject other) async { + final List addedFiles = []; + final List 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, + blacklistPrefixes: context.blacklistPrefixes)) { + continue; + } + if (await context.migrateUtils.isGitIgnored( + otherFile.absolute.path, other.directory.absolute.path)) { + context.migrateResult.diffMap[localPath] = + DiffResult(diffType: DiffType.ignored); + } + context.migrateResult.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)); + } + if (context.verbose) { + context.status.pause(); + context.logger.printStatus( + '${addedFiles.length} files were newly added in the target app.'); + context.status.resume(); + } + return addedFiles; + } + + /// Loops through each existing file and intelligently merges it with the base->target changes. + static Future merge( + MigrateContext context, + MigrateFlutterProject baseProject, + MigrateFlutterProject targetProject, + List unmanagedFiles, + List unmanagedDirectories, + bool preferTwoWayMerge, + ) async { + final List customMerges = [ + MetadataCustomMerge(logger: context.logger), + ]; + // For each existing file in the project, we attempt to 3 way merge if it is changed by the user. + final List currentFiles = + context.flutterProject.directory.listSync(recursive: true); + final String projectRootPath = + context.flutterProject.directory.absolute.path; + final Set missingAlwaysMigrateFiles = + Set.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 (context.migrateResult.diffMap.containsKey(localPath) && + context.migrateResult.diffMap[localPath]!.diffType == + DiffType.ignored || + await context.migrateUtils.isGitIgnored(currentFile.path, + context.flutterProject.directory.absolute.path) || + _skipped(localPath, context.fileSystem, + blacklistPrefixes: context.blacklistPrefixes) || + _skippedMerge(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 ((context.migrateResult.diffMap.containsKey(localPath) || + alwaysMigrate) && + context.migrateResult.diffMap[localPath] != null) { + // File changed between base and target + if (context.migrateResult.diffMap[localPath]!.diffType == + DiffType.deletion) { + // File is deleted in new template + context.migrateResult.deletedFiles + .add(FilePendingMigration(localPath, currentFile)); + continue; + } + if (context.migrateResult.diffMap[localPath]!.exitCode != 0 || + alwaysMigrate) { + // Accept the target version wholesale + MergeResult result; + try { + result = StringMergeResult.explicit( + mergedString: targetTemplateFile.readAsStringSync(), + hasConflict: false, + exitCode: 0, + localPath: localPath, + ); + } on FileSystemException { + result = BinaryMergeResult.explicit( + mergedBytes: targetTemplateFile.readAsBytesSync(), + hasConflict: false, + exitCode: 0, + localPath: localPath, + ); + } + context.migrateResult.mergeResults.add(result); + continue; + } + } + continue; + } + + // File changed by user + if (context.migrateResult.diffMap.containsKey(localPath)) { + MergeResult? result; + // Default to two way merge as it does not require the base file to exist. + MergeType mergeType = + context.migrateResult.mergeTypeMap[localPath] ?? MergeType.twoWay; + for (final CustomMerge customMerge in customMerges) { + if (customMerge.localPath == localPath) { + result = customMerge.merge( + currentFile, baseTemplateFile, targetTemplateFile); + mergeType = MergeType.custom; + break; + } + } + if (result == 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( + context + .migrateResult.generatedTargetTemplateDirectory!.path, + localPath); + break; + } + case MergeType.threeWay: + { + basePath = context.fileSystem.path.join( + context.migrateResult.generatedBaseTemplateDirectory!.path, + localPath); + currentPath = currentFile.path; + targetPath = context.fileSystem.path.join( + context + .migrateResult.generatedTargetTemplateDirectory!.path, + localPath); + break; + } + case MergeType.custom: + { + break; // handled above + } + } + if (mergeType != MergeType.custom) { + result = await context.migrateUtils.gitMergeFile( + base: basePath, + current: currentPath, + target: targetPath, + localPath: localPath, + ); + } + } + if (result != null) { + // Don't include if result is identical to the current file. + if (result is StringMergeResult) { + if (result.mergedString == currentFile.readAsStringSync()) { + context.status.pause(); + context.logger + .printStatus('$localPath was merged with a $mergeType.'); + context.status.resume(); + continue; + } + } else { + if ((result as BinaryMergeResult).mergedBytes == + currentFile.readAsBytesSync()) { + continue; + } + } + context.migrateResult.mergeResults.add(result); + } + if (context.verbose) { + context.status.pause(); + context.logger + .printStatus('$localPath was merged with a $mergeType.'); + context.status.resume(); + } + 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 = context + .migrateResult.generatedTargetTemplateDirectory! + .childFile(localPath); + if (targetTemplateFile.existsSync() && + !_skipped(localPath, context.fileSystem, + blacklistPrefixes: context.blacklistPrefixes)) { + context.migrateResult.addedFiles + .add(FilePendingMigration(localPath, targetTemplateFile)); + } + } + } +} + +/// The base reference project used in a migration computation. +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 createProject( + MigrateContext context, + List revisionsList, + Map> revisionToConfigs, + String fallbackRevision, + String targetRevision, + Directory targetFlutterDirectory, + ) async { + // Create base + // Clone base flutter + if (path == null) { + final Map revisionToFlutterSdkDir = + {}; + for (final String revision in revisionsList) { + final List platforms = []; + for (final MigratePlatformConfig config + in revisionToConfigs[revision]!) { + platforms.add(config.platform.toString().split('.').last); + } + platforms.remove( + 'root'); // Root does not need to be listed and is not a valid platform + + // 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 revisionsToTry = [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'); + context.migrateResult.sdkDirs[activeRevision] = sdkDir; + context.status.pause(); + context.logger.printStatus('Cloning SDK $activeRevision', + indent: 2, color: TerminalColor.grey); + context.status.resume(); + 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.status.pause(); + context.logger.printStatus( + 'Creating base app for $platforms with revision $revision.', + indent: 2, + color: TerminalColor.grey); + context.status.resume(); + final String newDirectoryPath = + await context.migrateUtils.createFromTemplates( + sdkDir.childDirectory('bin').absolute.path, + name: name, + androidLanguage: androidLanguage, + iosLanguage: iosLanguage, + outputDirectory: context + .migrateResult.generatedBaseTemplateDirectory!.absolute.path, + platforms: platforms, + ); + if (newDirectoryPath != + context.migrateResult.generatedBaseTemplateDirectory?.path) { + context.migrateResult.generatedBaseTemplateDirectory = + context.fileSystem.directory(newDirectoryPath); + } + // Determine merge type for each newly generated file. + final List generatedBaseFiles = context + .migrateResult.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, + context + .migrateResult.generatedBaseTemplateDirectory!.absolute.path, + context.fileSystem); + if (!context.migrateResult.mergeTypeMap.containsKey(localPath)) { + // Use two way merge when the base revision is the same as the target revision. + context.migrateResult.mergeTypeMap[localPath] = + revision == targetRevision + ? MergeType.twoWay + : MergeType.threeWay; + } + } + if (newDirectoryPath != + context.migrateResult.generatedBaseTemplateDirectory?.path) { + context.migrateResult.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. + } + } + } + } +} + +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 createProject( + MigrateContext context, + String targetRevision, + Directory targetFlutterDirectory, + ) async { + if (path == null) { + // Create target + context.status.pause(); + context.logger.printStatus( + 'Creating target app with revision $targetRevision.', + indent: 2, + color: TerminalColor.grey); + context.status.resume(); + if (context.verbose) { + context.logger.printStatus('Creating target app.'); + } + await context.migrateUtils.createFromTemplates( + targetFlutterDirectory.childDirectory('bin').absolute.path, + name: name, + androidLanguage: androidLanguage, + iosLanguage: iosLanguage, + outputDirectory: context + .migrateResult.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 platforms, + required FlutterToolsEnvironment environment, + }) { + _computeRevisions(context, baseRevision, allowFallbackBaseRevision, + platforms, environment); + } + + late List revisionsList; + late Map> revisionToConfigs; + late String fallbackRevision; + late String targetRevision; + late String? metadataRevision; + late MigrateConfig config; + + void _computeRevisions( + MigrateContext context, + String? baseRevision, + bool allowFallbackBaseRevision, + List platforms, + FlutterToolsEnvironment environment, + ) { + final FlutterProjectMetadata metadata = FlutterProjectMetadata( + context.flutterProject.directory.childFile('.metadata'), + context.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.logger, + ); + + metadataRevision = metadata.versionRevision; + if (environment.getString('FlutterVersion.frameworkRevision') == null) { + throwToolExit('Flutter framework revision was null'); + } + targetRevision = environment.getString('FlutterVersion.frameworkRevision')!; + String rootBaseRevision = ''; + revisionToConfigs = >{}; + final Set revisions = {}; + if (baseRevision == null) { + for (final MigratePlatformConfig platform + in config.platformConfigs.values) { + final String effectiveRevision = platform.baseRevision == null + ? metadataRevision ?? + _getFallbackBaseRevision(allowFallbackBaseRevision, + context.verbose, context.logger, context.status) + : platform.baseRevision!; + if (platforms != null && !platforms.contains(platform.platform)) { + continue; + } + if (platform.platform == SupportedPlatform.root) { + rootBaseRevision = effectiveRevision; + } + revisions.add(effectiveRevision); + if (revisionToConfigs[effectiveRevision] == null) { + revisionToConfigs[effectiveRevision] = []; + } + revisionToConfigs[effectiveRevision]!.add(platform); + } + } else { + rootBaseRevision = baseRevision; + revisionToConfigs[baseRevision] = []; + for (final SupportedPlatform platform in platforms) { + revisionToConfigs[baseRevision]!.add(MigratePlatformConfig( + platform: platform, baseRevision: baseRevision)); + } + } + // Reorder such that the root revision is created first. + revisions.remove(rootBaseRevision); + revisionsList = List.from(revisions); + if (rootBaseRevision != '') { + revisionsList.insert(0, rootBaseRevision); + } + if (context.verbose) { + context.logger.printStatus('Potential base revisions: $revisionsList'); + } + fallbackRevision = _getFallbackBaseRevision( + true, context.verbose, context.logger, context.status); + if (revisionsList.contains(fallbackRevision) && + baseRevision != fallbackRevision && + metadataRevision != fallbackRevision) { + context.status.pause(); + context.logger.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, + color: TerminalColor.grey); + context.status.resume(); + } + } +} diff --git a/packages/flutter_migrate/lib/src/custom_merge.dart b/packages/flutter_migrate/lib/src/custom_merge.dart new file mode 100644 index 00000000000..2f623922a77 --- /dev/null +++ b/packages/flutter_migrate/lib/src/custom_merge.dart @@ -0,0 +1,119 @@ +// 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 'base/logger.dart'; +import 'base/project.dart'; +import 'flutter_project_metadata.dart'; +import 'utils.dart'; + +/// Handles the custom/manual merging of one file at `localPath`. +/// +/// The `merge` method should be overridden to implement custom merging. +abstract class CustomMerge { + CustomMerge({ + required this.logger, + required this.localPath, + }); + + /// The local path (with the project root as the root directory) of the file to merge. + final String localPath; + final Logger logger; + + /// Called to perform a custom three way merge between the current, + /// base, and target files. + MergeResult merge(File current, File base, File target); +} + +/// Manually merges a flutter .metadata file. +/// +/// See `FlutterProjectMetadata`. +class MetadataCustomMerge extends CustomMerge { + MetadataCustomMerge({ + required super.logger, + }) : super(localPath: '.metadata'); + + @override + MergeResult merge(File current, File base, File target) { + final FlutterProjectMetadata result = computeMerge( + FlutterProjectMetadata(current, logger), + FlutterProjectMetadata(base, logger), + FlutterProjectMetadata(target, logger), + logger, + ); + return StringMergeResult.explicit( + mergedString: result.toString(), + hasConflict: false, + exitCode: 0, + localPath: localPath, + ); + } + + FlutterProjectMetadata computeMerge( + FlutterProjectMetadata current, + FlutterProjectMetadata base, + FlutterProjectMetadata target, + Logger logger) { + // Prefer to update the version revision and channel to latest version. + final String? versionRevision = target.versionRevision ?? + current.versionRevision ?? + base.versionRevision; + final String? versionChannel = + target.versionChannel ?? current.versionChannel ?? base.versionChannel; + // Prefer to leave the project type untouched as it is non-trivial to change project type. + final FlutterProjectType? projectType = + current.projectType ?? base.projectType ?? target.projectType; + final MigrateConfig migrateConfig = mergeMigrateConfig( + current.migrateConfig, + target.migrateConfig, + ); + final FlutterProjectMetadata output = FlutterProjectMetadata.explicit( + file: current.file, + versionRevision: versionRevision, + versionChannel: versionChannel, + projectType: projectType, + migrateConfig: migrateConfig, + logger: logger, + ); + return output; + } + + MigrateConfig mergeMigrateConfig( + MigrateConfig current, MigrateConfig target) { + // Create the superset of current and target platforms with baseRevision updated to be that of target. + final Map platformConfigs = + {}; + for (final MapEntry entry + in current.platformConfigs.entries) { + if (target.platformConfigs.containsKey(entry.key)) { + platformConfigs[entry.key] = MigratePlatformConfig( + platform: entry.value.platform, + createRevision: entry.value.createRevision, + baseRevision: target.platformConfigs[entry.key]?.baseRevision); + } else { + platformConfigs[entry.key] = entry.value; + } + } + for (final MapEntry entry + in target.platformConfigs.entries) { + if (!platformConfigs.containsKey(entry.key)) { + platformConfigs[entry.key] = entry.value; + } + } + + // Ignore the base file list. + final List unmanagedFiles = + List.from(current.unmanagedFiles); + for (final String path in target.unmanagedFiles) { + if (!unmanagedFiles.contains(path) && + !MigrateConfig.kDefaultUnmanagedFiles.contains(path)) { + unmanagedFiles.add(path); + } + } + return MigrateConfig( + platformConfigs: platformConfigs, + unmanagedFiles: unmanagedFiles, + ); + } +} diff --git a/packages/flutter_migrate/lib/src/environment.dart b/packages/flutter_migrate/lib/src/environment.dart new file mode 100644 index 00000000000..4b1268b49be --- /dev/null +++ b/packages/flutter_migrate/lib/src/environment.dart @@ -0,0 +1,71 @@ +// 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:convert'; +import 'dart:io'; + +import 'base/common.dart'; +import 'base/logger.dart'; + +/// Polls the flutter tool for details about the environment and project and exposes it as +/// a mapping of String keys to values. +class FlutterToolsEnvironment { + FlutterToolsEnvironment({ + required Map mapping, + }) : _mapping = mapping; + + static Future initializeFlutterToolsEnvironment( + Logger logger) async { + final ProcessResult result = await Process.run( + 'flutter', ['analyze', '--suggestions', '--machine']); + if (result.exitCode != 0) { + if ((result.stderr as String).contains( + 'The "--machine" flag is only valid with the "--version" flag.')) { + logger.printError( + 'The migrate tool is only compatible with flutter tools 3.4.0 or newer (git hash: 21861423f25ad03c2fdb33854b53f195bc117cb3).'); + } + throwToolExit( + 'Flutter tool exited while running `flutter analyze --suggestions --machine` with: ${result.stderr}'); + } + String commandOutput = result.stdout; + Map mapping = {}; + if (commandOutput.contains('{') && commandOutput.endsWith('}\n')) { + commandOutput = commandOutput.substring(commandOutput.indexOf('{')); + mapping = jsonDecode(result.stdout); + } + return FlutterToolsEnvironment(mapping: mapping); + } + + final Map _mapping; + + Object? operator [](String key) { + if (_mapping.containsKey(key)) { + return _mapping[key]; + } + return null; + } + + String? getString(String key) { + if (_mapping.containsKey(key) && + _mapping[key] != null && + _mapping[key] is String) { + return _mapping[key]! as String; + } + return null; + } + + bool? getBool(String key) { + if (_mapping.containsKey(key) && + _mapping[key] != null && + _mapping[key] is bool) { + return _mapping[key]! as bool; + } + return null; + } + + bool containsKey(String key) { + return _mapping.containsKey(key); + } +} diff --git a/packages/flutter_migrate/test/abandon_test.dart b/packages/flutter_migrate/test/abandon_test.dart new file mode 100644 index 00000000000..0175d344a43 --- /dev/null +++ b/packages/flutter_migrate/test/abandon_test.dart @@ -0,0 +1,138 @@ +// 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:flutter_migrate/src/base/context.dart'; +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:flutter_migrate/src/base/terminal.dart'; +import 'package:flutter_migrate/src/commands/abandon.dart'; +import 'package:flutter_migrate/src/utils.dart'; +import 'package:process/process.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; +import 'src/test_flutter_command_runner.dart'; + +void main() { + FileSystem fileSystem; + BufferLogger logger; + Terminal terminal; + ProcessManager processManager; + Directory appDir; + + setUp(() { + fileSystem = LocalFileSystem.test(signals: LocalSignals.instance); + appDir = fileSystem.systemTempDirectory.createTempSync('apptestdir'); + logger = BufferLogger.test(); + terminal = Terminal.test(); + processManager = const LocalProcessManager(); + }); + + setUpAll(() {}); + + tearDown(() async { + tryToDelete(appDir); + }); + + testUsingContext('abandon deletes staging directory', () async { + final MigrateAbandonCommand command = MigrateAbandonCommand( + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + processManager: processManager, + ); + final Directory stagingDir = + appDir.childDirectory(kDefaultMigrateStagingDirectoryName); + appDir.childFile('lib/main.dart').createSync(recursive: true); + final File pubspecOriginal = appDir.childFile('pubspec.yaml'); + pubspecOriginal.createSync(); + pubspecOriginal.writeAsStringSync(''' +name: originalname +description: A new Flutter project. +version: 1.0.0+1 +environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: true''', flush: true); + expect(stagingDir.existsSync(), false); + await createTestCommandRunner(command).run([ + 'abandon', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + expect(logger.errorText, contains('Provided staging directory')); + expect(logger.errorText, + contains('migrate_staging_dir` does not exist or is not valid.')); + + logger.clear(); + await createTestCommandRunner(command).run([ + 'abandon', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + expect(logger.statusText, + contains('No migration in progress. Start a new migration with:')); + + final File pubspecModified = stagingDir.childFile('pubspec.yaml'); + pubspecModified.createSync(recursive: true); + pubspecModified.writeAsStringSync(''' +name: newname +description: new description of the test project +version: 1.0.0+1 +environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: false + EXTRALINE''', flush: true); + + final File addedFile = stagingDir.childFile('added.file'); + addedFile.createSync(recursive: true); + addedFile.writeAsStringSync('new file contents'); + + final File manifestFile = stagingDir.childFile('.migrate_manifest'); + manifestFile.createSync(recursive: true); + manifestFile.writeAsStringSync(''' +merged_files: + - pubspec.yaml +conflict_files: +added_files: + - added.file +deleted_files: +'''); + + expect(appDir.childFile('lib/main.dart').existsSync(), true); + + expect(stagingDir.existsSync(), true); + logger.clear(); + await createTestCommandRunner(command).run([ + 'abandon', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--force', + '--flutter-subcommand', + ]); + expect(logger.statusText, + contains('Abandon complete. Start a new migration with:')); + expect(stagingDir.existsSync(), false); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); +} diff --git a/packages/flutter_migrate/test/apply_test.dart b/packages/flutter_migrate/test/apply_test.dart new file mode 100644 index 00000000000..0101e71e334 --- /dev/null +++ b/packages/flutter_migrate/test/apply_test.dart @@ -0,0 +1,227 @@ +// 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:flutter_migrate/src/base/context.dart'; +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:flutter_migrate/src/base/terminal.dart'; +import 'package:flutter_migrate/src/commands/apply.dart'; +import 'package:flutter_migrate/src/utils.dart'; +import 'package:process/process.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; +import 'src/test_flutter_command_runner.dart'; + +void main() { + FileSystem fileSystem; + BufferLogger logger; + Terminal terminal; + ProcessManager processManager; + Directory appDir; + + setUp(() { + fileSystem = LocalFileSystem.test(signals: LocalSignals.instance); + appDir = fileSystem.systemTempDirectory.createTempSync('apptestdir'); + logger = BufferLogger.test(); + terminal = Terminal.test(); + processManager = const LocalProcessManager(); + }); + + tearDown(() async { + tryToDelete(appDir); + }); + + testUsingContext('Apply produces all outputs', () async { + final MigrateApplyCommand command = MigrateApplyCommand( + verbose: true, + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + processManager: processManager, + ); + final Directory workingDir = + appDir.childDirectory(kDefaultMigrateStagingDirectoryName); + appDir.childFile('lib/main.dart').createSync(recursive: true); + final File pubspecOriginal = appDir.childFile('pubspec.yaml'); + pubspecOriginal.createSync(); + pubspecOriginal.writeAsStringSync(''' +name: originalname +description: A new Flutter project. +version: 1.0.0+1 +environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: true''', flush: true); + + final File gitignore = appDir.childFile('.gitignore'); + gitignore.createSync(); + gitignore.writeAsStringSync(kDefaultMigrateStagingDirectoryName, + flush: true); + + logger.clear(); + await createTestCommandRunner(command).run([ + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + expect( + logger.statusText, + contains( + 'Project is not a git repo. Please initialize a git repo and try again.')); + + await processManager + .run(['git', 'init'], workingDirectory: appDir.path); + + logger.clear(); + await createTestCommandRunner(command).run([ + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + expect(logger.statusText, contains('No migration in progress')); + + final File pubspecModified = workingDir.childFile('pubspec.yaml'); + pubspecModified.createSync(recursive: true); + pubspecModified.writeAsStringSync(''' +name: newname +description: new description of the test project +version: 1.0.0+1 +environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: false + # EXTRALINE:''', flush: true); + + final File addedFile = workingDir.childFile('added.file'); + addedFile.createSync(recursive: true); + addedFile.writeAsStringSync('new file contents'); + + final File manifestFile = workingDir.childFile('.migrate_manifest'); + manifestFile.createSync(recursive: true); + manifestFile.writeAsStringSync(''' +merged_files: + - pubspec.yaml +conflict_files: + - conflict/conflict.file +added_files: + - added.file +deleted_files: +'''); + + // Add conflict file + final File conflictFile = + workingDir.childDirectory('conflict').childFile('conflict.file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync(''' +line1 +<<<<<<< /conflcit/conflict.file +line2 +======= +linetwo +>>>>>>> /var/folders/md/gm0zgfcj07vcsj6jkh_mp_wh00ff02/T/flutter_tools.4Xdep8/generatedTargetTemplatetlN44S/conflict/conflict.file +line3 +''', flush: true); + + final File conflictFileOriginal = + appDir.childDirectory('conflict').childFile('conflict.file'); + conflictFileOriginal.createSync(recursive: true); + conflictFileOriginal.writeAsStringSync(''' +line1 +line2 +line3 +''', flush: true); + + logger.clear(); + await createTestCommandRunner(command).run([ + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + expect(logger.statusText, contains(r''' +Added files: + - added.file +Modified files: + - pubspec.yaml +Unable to apply migration. The following files in the migration working directory still have unresolved conflicts: + - conflict/conflict.file +Conflicting files found. Resolve these conflicts and try again. +Guided conflict resolution wizard: + + $ flutter migrate resolve-conflicts''')); + + conflictFile.writeAsStringSync(''' +line1 +linetwo +line3 +''', flush: true); + + logger.clear(); + await createTestCommandRunner(command).run([ + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + expect( + logger.statusText, + contains( + 'There are uncommitted changes in your project. Please git commit, abandon, or stash your changes before trying again.')); + + await processManager + .run(['git', 'add', '.'], workingDirectory: appDir.path); + await processManager.run(['git', 'commit', '-m', 'Initial commit'], + workingDirectory: appDir.path); + + logger.clear(); + await createTestCommandRunner(command).run([ + // 'migrate', + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + expect(logger.statusText, contains(r''' +Added files: + - added.file +Modified files: + - conflict/conflict.file + - pubspec.yaml + +Applying migration. + Modifying 3 files. +Writing pubspec.yaml +Writing conflict/conflict.file +Writing added.file +Updating .migrate_configs +Migration complete. You may use commands like `git status`, `git diff` and `git restore ` to continue working with the migrated files.''')); + + expect(pubspecOriginal.readAsStringSync(), contains('# EXTRALINE')); + expect(conflictFileOriginal.readAsStringSync(), contains('linetwo')); + expect(appDir.childFile('added.file').existsSync(), true); + expect(appDir.childFile('added.file').readAsStringSync(), + contains('new file contents')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); +} diff --git a/packages/flutter_migrate/test/compute_test.dart b/packages/flutter_migrate/test/compute_test.dart new file mode 100644 index 00000000000..a9ab8ef3049 --- /dev/null +++ b/packages/flutter_migrate/test/compute_test.dart @@ -0,0 +1,787 @@ +// 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:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/project.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:flutter_migrate/src/compute.dart'; +import 'package:flutter_migrate/src/environment.dart'; +import 'package:flutter_migrate/src/flutter_project_metadata.dart'; +import 'package:flutter_migrate/src/result.dart'; +import 'package:flutter_migrate/src/utils.dart'; +import 'package:process/process.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; +import 'src/test_utils.dart'; +import 'test_data/migrate_project.dart'; + +void main() { + late FileSystem fileSystem; + late BufferLogger logger; + late MigrateUtils utils; + late MigrateContext context; + late Directory targetFlutterDirectory; + late Directory newerTargetFlutterDirectory; + late Directory currentDir; + late FlutterToolsEnvironment environment; + + const String oldSdkRevision = '5391447fae6209bb21a89e6a5a6583cac1af9b4b'; + const String newSdkRevision = '85684f9300908116a78138ea4c6036c35c9a1236'; + + Future setUpFullEnv() async { + fileSystem = LocalFileSystem.test(signals: LocalSignals.instance); + currentDir = createResolvedTempDirectorySync('current_app.'); + logger = BufferLogger.test(); + utils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + processManager: const LocalProcessManager(), + ); + await MigrateProject.installProject('version:1.22.6_stable', currentDir); + final FlutterProjectFactory flutterFactory = FlutterProjectFactory(); + final FlutterProject flutterProject = + flutterFactory.fromDirectory(currentDir); + context = MigrateContext( + migrateResult: MigrateResult.empty(), + flutterProject: flutterProject, + blacklistPrefixes: {}, + logger: logger, + verbose: true, + fileSystem: fileSystem, + status: logger.startSpinner(), + migrateUtils: utils, + ); + targetFlutterDirectory = + createResolvedTempDirectorySync('targetFlutterDir.'); + newerTargetFlutterDirectory = + createResolvedTempDirectorySync('newerTargetFlutterDir.'); + environment = + await FlutterToolsEnvironment.initializeFlutterToolsEnvironment(logger); + await context.migrateUtils + .cloneFlutter(oldSdkRevision, targetFlutterDirectory.absolute.path); + await context.migrateUtils.cloneFlutter( + newSdkRevision, newerTargetFlutterDirectory.absolute.path); + } + + group('MigrateFlutterProject', () { + setUp(() async { + await setUpFullEnv(); + }); + + tearDown(() async { + tryToDelete(targetFlutterDirectory); + tryToDelete(newerTargetFlutterDirectory); + }); + + testUsingContext('MigrateTargetFlutterProject creates', () async { + final Directory workingDir = + createResolvedTempDirectorySync('migrate_working_dir.'); + final Directory targetDir = + createResolvedTempDirectorySync('target_dir.'); + context.migrateResult.generatedTargetTemplateDirectory = targetDir; + workingDir.createSync(recursive: true); + final MigrateTargetFlutterProject targetProject = + MigrateTargetFlutterProject( + path: null, + directory: targetDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + + await targetProject.createProject( + context, + oldSdkRevision, //targetRevision + targetFlutterDirectory, //targetFlutterDirectory + ); + + expect(targetDir.childFile('pubspec.yaml').existsSync(), true); + expect( + targetDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + true); + }); + + testUsingContext('MigrateBaseFlutterProject creates', () async { + final Directory workingDir = + createResolvedTempDirectorySync('migrate_working_dir.'); + final Directory baseDir = createResolvedTempDirectorySync('base_dir.'); + context.migrateResult.generatedBaseTemplateDirectory = baseDir; + workingDir.createSync(recursive: true); + final MigrateBaseFlutterProject baseProject = MigrateBaseFlutterProject( + path: null, + directory: baseDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + + await baseProject.createProject( + context, + [oldSdkRevision], //revisionsList + >{ + oldSdkRevision: [ + MigratePlatformConfig(platform: SupportedPlatform.android), + MigratePlatformConfig(platform: SupportedPlatform.ios) + ], + }, //revisionToConfigs + oldSdkRevision, //fallbackRevision + oldSdkRevision, //targetRevision + targetFlutterDirectory, //targetFlutterDirectory + ); + + expect(baseDir.childFile('pubspec.yaml').existsSync(), true); + expect( + baseDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + true); + }); + + testUsingContext('Migrate___FlutterProject skips when path exists', + () async { + final Directory workingDir = + createResolvedTempDirectorySync('migrate_working_dir.'); + final Directory targetDir = + createResolvedTempDirectorySync('target_dir.'); + final Directory baseDir = createResolvedTempDirectorySync('base_dir.'); + context.migrateResult.generatedTargetTemplateDirectory = targetDir; + context.migrateResult.generatedBaseTemplateDirectory = baseDir; + workingDir.createSync(recursive: true); + + final MigrateBaseFlutterProject baseProject = MigrateBaseFlutterProject( + path: 'some_existing_base_path', + directory: baseDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + final MigrateTargetFlutterProject targetProject = + MigrateTargetFlutterProject( + path: 'some_existing_target_path', + directory: targetDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + + await baseProject.createProject( + context, + [oldSdkRevision], //revisionsList + >{ + oldSdkRevision: [ + MigratePlatformConfig(platform: SupportedPlatform.android), + MigratePlatformConfig(platform: SupportedPlatform.ios) + ], + }, //revisionToConfigs + oldSdkRevision, //fallbackRevision + oldSdkRevision, //targetRevision + targetFlutterDirectory, //targetFlutterDirectory + ); + + expect(baseDir.childFile('pubspec.yaml').existsSync(), false); + expect( + baseDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + false); + + await targetProject.createProject( + context, + oldSdkRevision, //revisionsList + targetFlutterDirectory, //targetFlutterDirectory + ); + + expect(targetDir.childFile('pubspec.yaml').existsSync(), false); + expect( + targetDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + false); + }); + }); + + group('MigrateRevisions', () { + setUp(() async { + fileSystem = LocalFileSystem.test(signals: LocalSignals.instance); + currentDir = createResolvedTempDirectorySync('current_app.'); + logger = BufferLogger.test(); + utils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + processManager: const LocalProcessManager(), + ); + await MigrateProject.installProject('version:1.22.6_stable', currentDir); + final FlutterProjectFactory flutterFactory = FlutterProjectFactory(); + final FlutterProject flutterProject = + flutterFactory.fromDirectory(currentDir); + context = MigrateContext( + migrateResult: MigrateResult.empty(), + flutterProject: flutterProject, + blacklistPrefixes: {}, + logger: logger, + verbose: true, + fileSystem: fileSystem, + status: logger.startSpinner(), + migrateUtils: utils, + ); + }); + + testUsingContext('extracts revisions underpopulated metadata', () async { + final MigrateRevisions revisions = MigrateRevisions( + context: context, + baseRevision: oldSdkRevision, + allowFallbackBaseRevision: true, + platforms: [ + SupportedPlatform.android, + SupportedPlatform.ios + ], + environment: environment, + ); + + expect(revisions.revisionsList, [oldSdkRevision]); + expect(revisions.fallbackRevision, oldSdkRevision); + expect(revisions.metadataRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect(revisions.config.unmanagedFiles.isEmpty, false); + expect(revisions.config.platformConfigs.isEmpty, false); + expect(revisions.config.platformConfigs.length, 3); + expect( + revisions.config.platformConfigs.containsKey(SupportedPlatform.root), + true); + expect( + revisions.config.platformConfigs + .containsKey(SupportedPlatform.android), + true); + expect( + revisions.config.platformConfigs.containsKey(SupportedPlatform.ios), + true); + }); + + testUsingContext('extracts revisions full metadata', () async { + final File metadataFile = + context.flutterProject.directory.childFile('.metadata'); + if (metadataFile.existsSync()) { + metadataFile.deleteSync(); + } + metadataFile.createSync(recursive: true); + metadataFile.writeAsStringSync(''' +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + channel: unknown + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + base_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + - platform: android + create_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + base_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + - platform: ios + create_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + base_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + - platform: linux + create_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + base_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + - platform: macos + create_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + base_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + - platform: web + create_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + base_revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + - platform: windows + create_revision: 36427af29421f406ac95ff55ea31d1dc49a45b5f + base_revision: 36427af29421f406ac95ff55ea31d1dc49a45b5f + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'blah.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' +''', flush: true); + + final MigrateRevisions revisions = MigrateRevisions( + context: context, + baseRevision: oldSdkRevision, + allowFallbackBaseRevision: true, + platforms: [ + SupportedPlatform.android, + SupportedPlatform.ios + ], + environment: environment, + ); + + expect(revisions.revisionsList, [oldSdkRevision]); + expect(revisions.fallbackRevision, oldSdkRevision); + expect(revisions.metadataRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect(revisions.config.unmanagedFiles.isEmpty, false); + expect(revisions.config.unmanagedFiles.length, 3); + expect(revisions.config.unmanagedFiles.contains('lib/main.dart'), true); + expect(revisions.config.unmanagedFiles.contains('blah.dart'), true); + expect( + revisions.config.unmanagedFiles + .contains('ios/Runner.xcodeproj/project.pbxproj'), + true); + + expect(revisions.config.platformConfigs.length, 7); + expect( + revisions.config.platformConfigs.containsKey(SupportedPlatform.root), + true); + expect( + revisions.config.platformConfigs + .containsKey(SupportedPlatform.android), + true); + expect( + revisions.config.platformConfigs.containsKey(SupportedPlatform.ios), + true); + expect( + revisions.config.platformConfigs.containsKey(SupportedPlatform.linux), + true); + expect( + revisions.config.platformConfigs.containsKey(SupportedPlatform.macos), + true); + expect( + revisions.config.platformConfigs.containsKey(SupportedPlatform.web), + true); + expect( + revisions.config.platformConfigs + .containsKey(SupportedPlatform.windows), + true); + + expect( + revisions + .config.platformConfigs[SupportedPlatform.root]!.createRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions.config.platformConfigs[SupportedPlatform.android]! + .createRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.ios]!.createRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.linux]!.createRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.macos]!.createRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.web]!.createRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions.config.platformConfigs[SupportedPlatform.windows]! + .createRevision, + '36427af29421f406ac95ff55ea31d1dc49a45b5f'); + + expect( + revisions + .config.platformConfigs[SupportedPlatform.root]!.baseRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.android]!.baseRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions.config.platformConfigs[SupportedPlatform.ios]!.baseRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.linux]!.baseRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.macos]!.baseRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions.config.platformConfigs[SupportedPlatform.web]!.baseRevision, + '9b2d32b605630f28625709ebd9d78ab3016b2bf6'); + expect( + revisions + .config.platformConfigs[SupportedPlatform.windows]!.baseRevision, + '36427af29421f406ac95ff55ea31d1dc49a45b5f'); + }); + }); + + group('project operations', () { + setUp(() async { + await setUpFullEnv(); + }); + + tearDown(() async { + tryToDelete(targetFlutterDirectory); + tryToDelete(newerTargetFlutterDirectory); + }); + + testUsingContext('diff base and target', () async { + final Directory workingDir = + createResolvedTempDirectorySync('migrate_working_dir.'); + final Directory targetDir = + createResolvedTempDirectorySync('target_dir.'); + final Directory baseDir = createResolvedTempDirectorySync('base_dir.'); + context.migrateResult.generatedTargetTemplateDirectory = targetDir; + context.migrateResult.generatedBaseTemplateDirectory = baseDir; + workingDir.createSync(recursive: true); + + final MigrateBaseFlutterProject baseProject = MigrateBaseFlutterProject( + path: null, + directory: baseDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + final MigrateTargetFlutterProject targetProject = + MigrateTargetFlutterProject( + path: null, + directory: targetDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + + await baseProject.createProject( + context, + [oldSdkRevision], //revisionsList + >{ + oldSdkRevision: [ + MigratePlatformConfig(platform: SupportedPlatform.android), + MigratePlatformConfig(platform: SupportedPlatform.ios) + ], + }, //revisionToConfigs + oldSdkRevision, //fallbackRevision + oldSdkRevision, //targetRevision + targetFlutterDirectory, //targetFlutterDirectory + ); + + expect(baseDir.childFile('pubspec.yaml').existsSync(), true); + expect( + baseDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + true); + + await targetProject.createProject( + context, + newSdkRevision, //revisionsList + newerTargetFlutterDirectory, //targetFlutterDirectory + ); + + expect(targetDir.childFile('pubspec.yaml').existsSync(), true); + expect( + targetDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + true); + + final Map diffResults = + await baseProject.diff(context, targetProject); + context.migrateResult.diffMap.addAll(diffResults); + expect(diffResults.length, 62); + + final List expectedFiles = [ + '.metadata', + 'ios/Runner.xcworkspace/contents.xcworkspacedata', + 'ios/Runner/AppDelegate.h', + 'ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png', + 'ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png', + 'ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md', + 'ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json', + 'ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png', + 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png', + 'ios/Runner/Base.lproj/LaunchScreen.storyboard', + 'ios/Runner/Base.lproj/Main.storyboard', + 'ios/Runner/main.m', + 'ios/Runner/AppDelegate.m', + 'ios/Runner/Info.plist', + 'ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata', + 'ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', + 'ios/Flutter/Debug.xcconfig', + 'ios/Flutter/Release.xcconfig', + 'ios/Flutter/AppFrameworkInfo.plist', + 'pubspec.yaml', + '.gitignore', + 'android/base_android.iml', + 'android/app/build.gradle', + 'android/app/src/main/res/mipmap-mdpi/ic_launcher.png', + 'android/app/src/main/res/mipmap-hdpi/ic_launcher.png', + 'android/app/src/main/res/drawable/launch_background.xml', + 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png', + 'android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png', + 'android/app/src/main/res/values/styles.xml', + 'android/app/src/main/res/mipmap-xhdpi/ic_launcher.png', + 'android/app/src/main/AndroidManifest.xml', + 'android/app/src/main/java/com/example/base/MainActivity.java', + 'android/local.properties', + 'android/gradle/wrapper/gradle-wrapper.jar', + 'android/gradle/wrapper/gradle-wrapper.properties', + 'android/gradlew', + 'android/build.gradle', + 'android/gradle.properties', + 'android/gradlew.bat', + 'android/settings.gradle', + 'base.iml', + '.idea/runConfigurations/main_dart.xml', + '.idea/libraries/Dart_SDK.xml', + '.idea/libraries/KotlinJavaRuntime.xml', + '.idea/libraries/Flutter_for_Android.xml', + '.idea/workspace.xml', + '.idea/modules.xml', + ]; + for (final String expectedFile in expectedFiles) { + expect(diffResults.containsKey(expectedFile), true); + } + // Spot check diffs on key files: + expect(diffResults['android/build.gradle']!.diff, contains(r''' +@@ -1,18 +1,20 @@ + buildscript { ++ ext.kotlin_version = '1.6.10' + repositories { + google() +- jcenter() ++ mavenCentral() + }''')); + expect(diffResults['android/build.gradle']!.diff, contains(r''' + dependencies { +- classpath 'com.android.tools.build:gradle:3.2.1' ++ classpath 'com.android.tools.build:gradle:7.1.2' ++ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } + }''')); + expect(diffResults['android/build.gradle']!.diff, contains(r''' + allprojects { + repositories { + google() +- jcenter() ++ mavenCentral() + } + }''')); + expect(diffResults['android/app/src/main/AndroidManifest.xml']!.diff, + contains(r''' +@@ -1,39 +1,34 @@ + +- +- +- +- +- +- + +- ++ + ++ android:name="io.flutter.embedding.android.NormalTheme" ++ android:resource="@style/NormalTheme" ++ /> + + + + + ++ ++ + + ''')); + }); + + testUsingContext('Merge succeeds', () async { + final Directory workingDir = + createResolvedTempDirectorySync('migrate_working_dir.'); + final Directory targetDir = + createResolvedTempDirectorySync('target_dir.'); + final Directory baseDir = createResolvedTempDirectorySync('base_dir.'); + context.migrateResult.generatedTargetTemplateDirectory = targetDir; + context.migrateResult.generatedBaseTemplateDirectory = baseDir; + workingDir.createSync(recursive: true); + + final MigrateBaseFlutterProject baseProject = MigrateBaseFlutterProject( + path: null, + directory: baseDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + final MigrateTargetFlutterProject targetProject = + MigrateTargetFlutterProject( + path: null, + directory: targetDir, + name: 'base', + androidLanguage: 'java', + iosLanguage: 'objc', + ); + + await baseProject.createProject( + context, + [oldSdkRevision], //revisionsList + >{ + oldSdkRevision: [ + MigratePlatformConfig(platform: SupportedPlatform.android), + MigratePlatformConfig(platform: SupportedPlatform.ios) + ], + }, //revisionToConfigs + oldSdkRevision, //fallbackRevision + oldSdkRevision, //targetRevision + targetFlutterDirectory, //targetFlutterDirectory + ); + + expect(baseDir.childFile('pubspec.yaml').existsSync(), true); + expect(baseDir.childFile('.metadata').existsSync(), true); + expect( + baseDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + true); + + await targetProject.createProject( + context, + newSdkRevision, //revisionsList + newerTargetFlutterDirectory, //targetFlutterDirectory + ); + + expect(targetDir.childFile('pubspec.yaml').existsSync(), true); + expect(targetDir.childFile('.metadata').existsSync(), true); + expect( + targetDir + .childDirectory('android') + .childFile('build.gradle') + .existsSync(), + true); + + context.migrateResult.diffMap + .addAll(await baseProject.diff(context, targetProject)); + + await MigrateFlutterProject.merge( + context, + baseProject, + targetProject, + [], // unmanagedFiles + [], // unmanagedDirectories + false, // preferTwoWayMerge + ); + + expect(context.migrateResult.mergeResults.length, 12); + expect(context.migrateResult.mergeResults[0].localPath, '.metadata'); + expect(context.migrateResult.mergeResults[1].localPath, + 'ios/Runner/Info.plist'); + expect(context.migrateResult.mergeResults[2].localPath, + 'ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata'); + expect(context.migrateResult.mergeResults[3].localPath, + 'ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme'); + expect(context.migrateResult.mergeResults[4].localPath, + 'ios/Flutter/AppFrameworkInfo.plist'); + expect(context.migrateResult.mergeResults[5].localPath, 'pubspec.yaml'); + expect(context.migrateResult.mergeResults[6].localPath, '.gitignore'); + expect(context.migrateResult.mergeResults[7].localPath, + 'android/app/build.gradle'); + expect(context.migrateResult.mergeResults[8].localPath, + 'android/app/src/main/res/values/styles.xml'); + expect(context.migrateResult.mergeResults[9].localPath, + 'android/app/src/main/AndroidManifest.xml'); + expect(context.migrateResult.mergeResults[10].localPath, + 'android/gradle/wrapper/gradle-wrapper.properties'); + expect(context.migrateResult.mergeResults[11].localPath, + 'android/build.gradle'); + + expect(context.migrateResult.mergeResults[0].exitCode, 0); + expect(context.migrateResult.mergeResults[1].exitCode, 0); + expect(context.migrateResult.mergeResults[2].exitCode, 0); + expect(context.migrateResult.mergeResults[3].exitCode, 0); + expect(context.migrateResult.mergeResults[4].exitCode, 0); + expect(context.migrateResult.mergeResults[5].exitCode, 0); + expect(context.migrateResult.mergeResults[6].exitCode, 0); + expect(context.migrateResult.mergeResults[7].exitCode, 0); + expect(context.migrateResult.mergeResults[8].exitCode, 0); + expect(context.migrateResult.mergeResults[9].exitCode, 0); + expect(context.migrateResult.mergeResults[10].exitCode, 0); + expect(context.migrateResult.mergeResults[11].exitCode, 0); + + expect(context.migrateResult.mergeResults[0].hasConflict, false); + expect(context.migrateResult.mergeResults[1].hasConflict, false); + expect(context.migrateResult.mergeResults[2].hasConflict, false); + expect(context.migrateResult.mergeResults[3].hasConflict, false); + expect(context.migrateResult.mergeResults[4].hasConflict, false); + expect(context.migrateResult.mergeResults[5].hasConflict, false); + expect(context.migrateResult.mergeResults[6].hasConflict, false); + expect(context.migrateResult.mergeResults[7].hasConflict, false); + expect(context.migrateResult.mergeResults[8].hasConflict, false); + expect(context.migrateResult.mergeResults[9].hasConflict, false); + expect(context.migrateResult.mergeResults[10].hasConflict, false); + expect(context.migrateResult.mergeResults[11].hasConflict, false); + }); + }); +} diff --git a/packages/flutter_migrate/test/custom_merge_test.dart b/packages/flutter_migrate/test/custom_merge_test.dart new file mode 100644 index 00000000000..c8decf0c2e5 --- /dev/null +++ b/packages/flutter_migrate/test/custom_merge_test.dart @@ -0,0 +1,279 @@ +// 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_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/custom_merge.dart'; +import 'package:flutter_migrate/src/utils.dart'; + +import 'src/common.dart'; + +void main() { + late FileSystem fileSystem; + late BufferLogger logger; + + setUpAll(() { + fileSystem = MemoryFileSystem.test(); + logger = BufferLogger.test(); + }); + + group('.metadata merge', () { + late MetadataCustomMerge merger; + + setUp(() { + merger = MetadataCustomMerge(logger: logger); + }); + + testWithoutContext('merges empty', () async { + const String current = ''; + const String base = ''; + const String target = ''; + final File currentFile = fileSystem.file('.metadata_current'); + final File baseFile = fileSystem.file('.metadata_base'); + final File targetFile = fileSystem.file('.metadata_target'); + + currentFile + ..createSync(recursive: true) + ..writeAsStringSync(current, flush: true); + baseFile + ..createSync(recursive: true) + ..writeAsStringSync(base, flush: true); + targetFile + ..createSync(recursive: true) + ..writeAsStringSync(target, flush: true); + + final StringMergeResult result = + merger.merge(currentFile, baseFile, targetFile) as StringMergeResult; + expect( + result.mergedString, + ''' +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: null + channel: null + +project_type: ''' + ''' + + +# Tracks metadata for the flutter migrate command +migration: + platforms: + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' +'''); + }); + + testWithoutContext('merge adds migration section', () async { + const String current = ''' +# my own comment +version: + revision: abcdefg12345 + channel: stable +project_type: app + '''; + const String base = ''' +version: + revision: abcdefg12345base + channel: stable +project_type: app +migration: + platforms: + - platform: root + create_revision: somecreaterevision + base_revision: somebaserevision + - platform: android + create_revision: somecreaterevision + base_revision: somebaserevision + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' + '''; + const String target = ''' +version: + revision: abcdefg12345target + channel: stable +project_type: app +migration: + platforms: + - platform: root + create_revision: somecreaterevision + base_revision: somebaserevision + - platform: android + create_revision: somecreaterevision + base_revision: somebaserevision + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' + '''; + final File currentFile = fileSystem.file('.metadata_current'); + final File baseFile = fileSystem.file('.metadata_base'); + final File targetFile = fileSystem.file('.metadata_target'); + + currentFile + ..createSync(recursive: true) + ..writeAsStringSync(current, flush: true); + baseFile + ..createSync(recursive: true) + ..writeAsStringSync(base, flush: true); + targetFile + ..createSync(recursive: true) + ..writeAsStringSync(target, flush: true); + + final StringMergeResult result = + merger.merge(currentFile, baseFile, targetFile) as StringMergeResult; + expect(result.mergedString, ''' +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: abcdefg12345target + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: somecreaterevision + base_revision: somebaserevision + - platform: android + create_revision: somecreaterevision + base_revision: somebaserevision + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' +'''); + }); + + testWithoutContext('merge handles standard migration flow', () async { + const String current = ''' +# my own comment +version: + revision: abcdefg12345current + channel: stable +project_type: app +migration: + platforms: + - platform: root + create_revision: somecreaterevisioncurrent + base_revision: somebaserevisioncurrent + - platform: android + create_revision: somecreaterevisioncurrent + base_revision: somebaserevisioncurrent + unmanaged_files: + - 'lib/main.dart' + - 'new/file.dart' + '''; + const String base = ''' +version: + revision: abcdefg12345base + channel: stable +project_type: app +migration: + platforms: + - platform: root + create_revision: somecreaterevisionbase + base_revision: somebaserevisionbase + - platform: android + create_revision: somecreaterevisionbase + base_revision: somebaserevisionbase + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' + '''; + const String target = ''' +version: + revision: abcdefg12345target + channel: stable +project_type: app +migration: + platforms: + - platform: root + create_revision: somecreaterevisiontarget + base_revision: somebaserevisiontarget + - platform: android + create_revision: somecreaterevisiontarget + base_revision: somebaserevisiontarget + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' + - 'extra/file' + '''; + final File currentFile = fileSystem.file('.metadata_current'); + final File baseFile = fileSystem.file('.metadata_base'); + final File targetFile = fileSystem.file('.metadata_target'); + + currentFile + ..createSync(recursive: true) + ..writeAsStringSync(current, flush: true); + baseFile + ..createSync(recursive: true) + ..writeAsStringSync(base, flush: true); + targetFile + ..createSync(recursive: true) + ..writeAsStringSync(target, flush: true); + + final StringMergeResult result = + merger.merge(currentFile, baseFile, targetFile) as StringMergeResult; + expect(result.mergedString, ''' +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: abcdefg12345target + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: somecreaterevisioncurrent + base_revision: somebaserevisiontarget + - platform: android + create_revision: somecreaterevisioncurrent + base_revision: somebaserevisiontarget + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'new/file.dart' + - 'extra/file' +'''); + }); + }); +} diff --git a/packages/flutter_migrate/test/migrate_test.dart b/packages/flutter_migrate/test/migrate_test.dart new file mode 100644 index 00000000000..4e09b1679a7 --- /dev/null +++ b/packages/flutter_migrate/test/migrate_test.dart @@ -0,0 +1,460 @@ +// 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:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/project.dart'; +import 'package:flutter_migrate/src/base/terminal.dart'; +import 'package:flutter_migrate/src/compute.dart'; +import 'package:flutter_migrate/src/environment.dart'; +import 'package:flutter_migrate/src/result.dart'; +import 'package:flutter_migrate/src/utils.dart'; +import 'package:process/process.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; +import 'src/test_utils.dart'; +import 'test_data/migrate_project.dart'; + +void main() { + Directory tempDir; + BufferLogger logger; + MigrateUtils utils; + ProcessManager processManager; + FlutterToolsEnvironment environment; + + setUp(() async { + tempDir = createResolvedTempDirectorySync('run_test.'); + logger = BufferLogger.test(); + processManager = const LocalProcessManager(); + utils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + processManager: processManager, + ); + environment = FlutterToolsEnvironment(mapping: { + 'FlutterProject.directory': '/projects/hello_world', + 'FlutterProject.metadataFile': '/projects/hello_world/.metadata', + 'FlutterProject.android.exists': true, + 'FlutterProject.ios.exists': true, + 'FlutterProject.web.exists': true, + 'FlutterProject.macos.exists': true, + 'FlutterProject.linux.exists': true, + 'FlutterProject.windows.exists': true, + 'FlutterProject.fuchsia.exists': false, + 'FlutterProject.android.isKotlin': true, + 'FlutterProject.ios.isSwift': true, + 'FlutterProject.isModule': false, + 'FlutterProject.isPlugin': false, + 'FlutterProject.manifest.appname': 'hello_world', + 'FlutterVersion.frameworkRevision': '', + 'Platform.operatingSystem': 'macos', + 'Platform.isAndroid': false, + 'Platform.isIOS': false, + 'Platform.isWindows': false, + 'Platform.isMacOS': true, + 'Platform.isFuchsia': false, + 'Platform.pathSeparator': '/', + 'Cache.flutterRoot': '/flutter' + }); + }); + + tearDown(() async { + tryToDelete(tempDir); + }); + + // Migrates a clean untouched app generated with flutter create + testUsingContext('vanilla migrate process succeeds', () async { + // Flutter Stable 1.22.6 hash: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + await MigrateProject.installProject('version:1.22.6_stable', tempDir); + + ProcessResult result = await runMigrateCommand([ + 'start', + '--verbose', + ], workingDirectory: tempDir.path); + print(result.stdout); + expect(result.stdout.toString(), contains('Staging directory created at')); + expect(result.stdout.toString(), contains(''' + Added files: + - android/app/src/main/res/values-night/styles.xml + - android/app/src/main/res/drawable-v21/launch_background.xml + - analysis_options.yaml + Modified files: + - .metadata + - ios/Runner/Info.plist + - ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata + - ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme + - ios/Flutter/AppFrameworkInfo.plist + - ios/.gitignore + - pubspec.yaml + - .gitignore + - android/app/build.gradle + - android/app/src/profile/AndroidManifest.xml + - android/app/src/main/res/values/styles.xml + - android/app/src/main/AndroidManifest.xml + - android/app/src/debug/AndroidManifest.xml + - android/gradle/wrapper/gradle-wrapper.properties + - android/.gitignore + - android/build.gradle''')); + + result = await runMigrateCommand([ + 'apply', + '--verbose', + ], workingDirectory: tempDir.path); + logger.printStatus('${result.exitCode}', color: TerminalColor.blue); + logger.printStatus(result.stdout, color: TerminalColor.green); + logger.printStatus(result.stderr, color: TerminalColor.red); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('Migration complete')); + + expect(tempDir.childFile('.metadata').readAsStringSync(), + contains('migration:\n platforms:\n - platform: root\n')); + + expect( + tempDir + .childFile('android/app/src/main/res/values-night/styles.xml') + .existsSync(), + true); + expect(tempDir.childFile('analysis_options.yaml').existsSync(), true); + }); + + // Migrates a clean untouched app generated with flutter create + testUsingContext('vanilla migrate builds', () async { + // Flutter Stable 2.0.0 hash: 60bd88df915880d23877bfc1602e8ddcf4c4dd2a + await MigrateProject.installProject('version:2.0.0_stable', tempDir, + main: ''' +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: Container(), + ); + } +} +'''); + ProcessResult result = await runMigrateCommand([ + 'start', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.stdout.toString(), contains('Working directory created at')); + + result = await runMigrateCommand([ + 'apply', + '--verbose', + ], workingDirectory: tempDir.path); + logger.printStatus('${result.exitCode}', color: TerminalColor.blue); + logger.printStatus(result.stdout, color: TerminalColor.green); + logger.printStatus(result.stderr, color: TerminalColor.red); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('Migration complete')); + + result = await runMigrateCommand([ + 'build', + 'apk', + '--debug', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('app-debug.apk')); + }); + + testUsingContext('migrate abandon', () async { + // Abandon in an empty dir fails. + ProcessResult result = await runMigrateCommand([ + 'abandon', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 1); + expect(result.stderr.toString(), + contains('Error: No pubspec.yaml file found')); + expect( + result.stderr.toString(), + contains( + 'This command should be run from the root of your Flutter project')); + + final File manifestFile = + tempDir.childFile('migrate_working_dir/.migrate_manifest'); + expect(manifestFile.existsSync(), false); + + // Flutter Stable 1.22.6 hash: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + await MigrateProject.installProject('version:1.22.6_stable', tempDir); + + // Initialized repo fails. + result = await runMigrateCommand([ + 'abandon', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('No migration')); + + // Create migration. + manifestFile.createSync(recursive: true); + + // Directory with manifest_working_dir succeeds. + result = await runMigrateCommand([ + 'abandon', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('Abandon complete')); + }); + + testUsingContext('migrate compute', () async { + // Flutter Stable 1.22.6 hash: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + await MigrateProject.installProject('version:1.22.6_stable', tempDir); + + final FlutterProjectFactory flutterFactory = FlutterProjectFactory(); + final FlutterProject flutterProject = flutterFactory.fromDirectory(tempDir); + + final MigrateResult result = await computeMigration( + verbose: true, + flutterProject: flutterProject, + deleteTempDirectories: false, + fileSystem: fileSystem, + logger: logger, + migrateUtils: utils, + environment: environment, + ); + expect(result.sdkDirs.length, equals(1)); + expect(result.deletedFiles.isEmpty, true); + expect(result.addedFiles.isEmpty, false); + expect(result.mergeResults.isEmpty, false); + expect(result.generatedBaseTemplateDirectory, isNotNull); + expect(result.generatedTargetTemplateDirectory, isNotNull); + }); + + // Migrates a user-modified app + testUsingContext('modified migrate process succeeds', () async { + // Flutter Stable 1.22.6 hash: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + await MigrateProject.installProject('version:1.22.6_stable', tempDir, + vanilla: false); + + ProcessResult result = await runMigrateCommand([ + 'apply', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('No migration')); + + result = await runMigrateCommand([ + 'status', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('No migration')); + + result = await runMigrateCommand([ + 'start', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('Working directory created at')); + expect(result.stdout.toString(), contains(''' + Modified files: + - .metadata + - ios/Runner/Info.plist + - ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata + - ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme + - ios/Flutter/AppFrameworkInfo.plist + - ios/.gitignore + - .gitignore + - android/app/build.gradle + - android/app/src/profile/AndroidManifest.xml + - android/app/src/main/res/values/styles.xml + - android/app/src/main/AndroidManifest.xml + - android/app/src/debug/AndroidManifest.xml + - android/gradle/wrapper/gradle-wrapper.properties + - android/.gitignore + - android/build.gradle + Merge conflicted files: +[ ] - pubspec.yaml''')); + + // Call apply with conflicts remaining. Should fail. + result = await runMigrateCommand([ + 'apply', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect( + result.stdout.toString(), + contains( + 'Conflicting files found. Resolve these conflicts and try again.')); + expect(result.stdout.toString(), contains('] - pubspec.yaml')); + + result = await runMigrateCommand([ + 'status', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('Modified files')); + expect(result.stdout.toString(), contains('Merge conflicted files')); + + // Manually resolve conflics. The correct contents for resolution may change over time, + // but it shouldnt matter for this test. + final File metadataFile = + tempDir.childFile('migrate_working_dir/.metadata'); + metadataFile.writeAsStringSync(''' +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: e96a72392696df66755ca246ff291dfc6ca6c4ad + channel: unknown + +project_type: app + +''', flush: true); + final File pubspecYamlFile = + tempDir.childFile('migrate_working_dir/pubspec.yaml'); + pubspecYamlFile.writeAsStringSync(''' +name: vanilla_app_1_22_6_stable +description: This is a modified description from the default. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.17.0-79.0.dev <3.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^1.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - images/a_dot_burr.jpeg + - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages + +''', flush: true); + + result = await runMigrateCommand([ + 'status', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('Modified files')); + expect(result.stdout.toString(), contains('diff --git')); + expect(result.stdout.toString(), contains('@@')); + expect(result.stdout.toString(), isNot(contains('Merge conflicted files'))); + + result = await runMigrateCommand([ + 'apply', + '--verbose', + ], workingDirectory: tempDir.path); + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('Migration complete')); + + expect(tempDir.childFile('.metadata').readAsStringSync(), + contains('e96a72392696df66755ca246ff291dfc6ca6c4ad')); + expect(tempDir.childFile('pubspec.yaml').readAsStringSync(), + isNot(contains('">=2.6.0 <3.0.0"'))); + expect(tempDir.childFile('pubspec.yaml').readAsStringSync(), + contains('">=2.17.0-79.0.dev <3.0.0"')); + expect( + tempDir.childFile('pubspec.yaml').readAsStringSync(), + contains( + 'description: This is a modified description from the default.')); + expect(tempDir.childFile('lib/main.dart').readAsStringSync(), + contains('OtherWidget()')); + expect(tempDir.childFile('lib/other.dart').existsSync(), true); + expect(tempDir.childFile('lib/other.dart').readAsStringSync(), + contains('class OtherWidget')); + + expect( + tempDir + .childFile('android/app/src/main/res/values-night/styles.xml') + .existsSync(), + true); + expect(tempDir.childFile('analysis_options.yaml').existsSync(), true); + }); +} diff --git a/packages/flutter_migrate/test/resolve_conflicts_test.dart b/packages/flutter_migrate/test/resolve_conflicts_test.dart new file mode 100644 index 00000000000..0dc3a76d4cf --- /dev/null +++ b/packages/flutter_migrate/test/resolve_conflicts_test.dart @@ -0,0 +1,744 @@ +// 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:flutter_migrate/src/base/context.dart'; +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:flutter_migrate/src/base/terminal.dart'; +import 'package:flutter_migrate/src/commands/resolve_conflicts.dart'; +import 'package:flutter_migrate/src/manifest.dart'; +import 'package:flutter_migrate/src/result.dart'; +import 'package:flutter_migrate/src/utils.dart'; +import 'package:process/process.dart'; +import 'package:test/fake.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; +import 'src/fakes.dart'; +import 'src/test_flutter_command_runner.dart'; + +void main() { + FileSystem fileSystem; + BufferLogger logger; + FakeTerminal terminal; + ProcessManager processManager; + Directory appDir; + Directory stagingDir; + MigrateResolveConflictsCommand command; + + setUp(() { + fileSystem = LocalFileSystem.test(signals: LocalSignals.instance); + appDir = fileSystem.systemTempDirectory.createTempSync('apptestdir'); + appDir.createSync(recursive: true); + appDir.childFile('lib/main.dart').createSync(recursive: true); + stagingDir = appDir.childDirectory('migrate_working_dir'); + stagingDir.createSync(recursive: true); + logger = BufferLogger.test(); + terminal = FakeTerminal(); + processManager = const LocalProcessManager(); + + final File pubspecOriginal = appDir.childFile('pubspec.yaml'); + pubspecOriginal.createSync(recursive: true); + pubspecOriginal.writeAsStringSync(''' +name: originalname +description: A new Flutter project. +version: 1.0.0+1 +environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: true''', flush: true); + command = MigrateResolveConflictsCommand( + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + ); + }); + + tearDown(() async { + tryToDelete(appDir); + }); + + testUsingContext('commits new simple conflict', () async { + final File conflictFile = stagingDir.childFile('conflict_file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + flush: true); + + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + 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: {}, + )); + manifest.writeFile(); + + expect(stagingDir.existsSync(), true); + final Future commandFuture = + createTestCommandRunner(command).run([ + 'resolve-conflicts', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + terminal.simulateStdin('n'); + terminal.simulateStdin('y'); + + await commandFuture; + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 hello + 1 wow a bunch of lines + 2 <<<<<<< + 3 original + 4 ======= + 5 new + 6 >>>>>>> + 7 hi''')); + expect(logger.statusText, contains(''' +Conflict in conflict_file. +Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit. [o|n|s|q]: n + + +Conflicts in conflict_file complete. + +You chose to: + Skip 0 conflicts + Acccept the original lines for 0 conflicts + Accept the new lines for 1 conflicts + +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]:''')); + expect(conflictFile.readAsStringSync(), + equals('hello\nwow a bunch of lines\nnew\nhi\n')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); + + testUsingContext('skip commit simple conflict leaves intact', () async { + final File conflictFile = stagingDir.childFile('conflict_file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + flush: true); + + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + 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: {}, + )); + manifest.writeFile(); + + expect(stagingDir.existsSync(), true); + final Future commandFuture = + createTestCommandRunner(command).run([ + 'resolve-conflicts', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + terminal.simulateStdin('n'); + terminal.simulateStdin('n'); + + await commandFuture; + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 hello + 1 wow a bunch of lines + 2 <<<<<<< + 3 original + 4 ======= + 5 new + 6 >>>>>>> + 7 hi''')); + expect(logger.statusText, contains(''' +Conflict in conflict_file. +Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit. [o|n|s|q]: n + + +Conflicts in conflict_file complete. + +You chose to: + Skip 0 conflicts + Acccept the original lines for 0 conflicts + Accept the new lines for 1 conflicts + +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]:''')); + expect( + conflictFile.readAsStringSync(), + equals( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); + + testUsingContext('commits original simple conflict', () async { + final File conflictFile = stagingDir.childFile('conflict_file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + flush: true); + + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + 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: {}, + )); + manifest.writeFile(); + + expect(stagingDir.existsSync(), true); + final Future commandFuture = + createTestCommandRunner(command).run([ + 'resolve-conflicts', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + terminal.simulateStdin('o'); + terminal.simulateStdin('y'); + + await commandFuture; + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 hello + 1 wow a bunch of lines + 2 <<<<<<< + 3 original + 4 ======= + 5 new + 6 >>>>>>> + 7 hi''')); + expect(logger.statusText, contains(''' +Conflict in conflict_file. +Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit. [o|n|s|q]: o + + +Conflicts in conflict_file complete. + +You chose to: + Skip 0 conflicts + Acccept the original lines for 1 conflicts + Accept the new lines for 0 conflicts + +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]:''')); + expect(conflictFile.readAsStringSync(), + equals('hello\nwow a bunch of lines\noriginal\nhi\n')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); + + testUsingContext('skip conflict leaves file unchanged', () async { + final File conflictFile = stagingDir.childFile('conflict_file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + flush: true); + + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + 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: {}, + )); + manifest.writeFile(); + + expect(stagingDir.existsSync(), true); + final Future commandFuture = + createTestCommandRunner(command).run([ + 'resolve-conflicts', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + terminal.simulateStdin('s'); + terminal.simulateStdin('y'); + + await commandFuture; + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 hello + 1 wow a bunch of lines + 2 <<<<<<< + 3 original + 4 ======= + 5 new + 6 >>>>>>> + 7 hi''')); + expect( + conflictFile.readAsStringSync(), + equals( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); + + testUsingContext('partial conflict skipped.', () async { + final File conflictFile = stagingDir.childFile('conflict_file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n<<<<<<<\nskip this partial conflict\n=======\nblah blah', + flush: true); + + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n<<<<<<<\nskip this partial conflict\n=======\nblah blah', + 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: {}, + )); + manifest.writeFile(); + + expect(stagingDir.existsSync(), true); + final Future commandFuture = + createTestCommandRunner(command).run([ + 'resolve-conflicts', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + terminal.simulateStdin('o'); + terminal.simulateStdin('y'); + + await commandFuture; + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 hello + 1 wow a bunch of lines + 2 <<<<<<< + 3 original + 4 ======= + 5 new + 6 >>>>>>> + 7 hi''')); + expect(logger.statusText, contains(''' +Conflict in conflict_file. +Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit. [o|n|s|q]: o + + +Conflicts in conflict_file complete. + +You chose to: + Skip 0 conflicts + Acccept the original lines for 1 conflicts + Accept the new lines for 0 conflicts + +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]:''')); + expect( + conflictFile.readAsStringSync(), + equals( + 'hello\nwow a bunch of lines\noriginal\nhi\n<<<<<<<\nskip this partial conflict\n=======\nblah blah\n')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); + + testUsingContext('multiple files sequence', () async { + final File conflictFile = stagingDir.childFile('conflict_file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + flush: true); + final File conflictFile2 = stagingDir.childFile('conflict_file2'); + conflictFile2.createSync(recursive: true); + conflictFile2.writeAsStringSync( + 'MoreConflicts\n<<<<<<<\noriginal\nmultiple\nlines\n=======\nnew\n>>>>>>>\nhi\n', + flush: true); + + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n', + hasConflict: true, + exitCode: 1, + ), + StringMergeResult.explicit( + localPath: 'conflict_file2', + mergedString: + 'MoreConflicts\n<<<<<<<\noriginal\nmultiple\nlines\n=======\nnew\n>>>>>>>\nhi\n', + 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: {}, + )); + manifest.writeFile(); + + expect(stagingDir.existsSync(), true); + final Future commandFuture = + createTestCommandRunner(command).run([ + 'resolve-conflicts', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + terminal.simulateStdin('n'); + terminal.simulateStdin('y'); + terminal.simulateStdin('o'); + terminal.simulateStdin('y'); + + await commandFuture; + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 hello + 1 wow a bunch of lines + 2 <<<<<<< + 3 original + 4 ======= + 5 new + 6 >>>>>>> + 7 hi''')); + expect(logger.statusText, contains(''' +Conflict in conflict_file. +Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit. [o|n|s|q]: n + + +Conflicts in conflict_file complete. + +You chose to: + Skip 0 conflicts + Acccept the original lines for 0 conflicts + Accept the new lines for 1 conflicts + +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]:''')); + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 MoreConflicts + 1 <<<<<<< + 2 original + 3 multiple + 4 lines + 5 ======= + 6 new + 7 >>>>>>> + 8 hi''')); + expect(logger.statusText, contains(''' +Conflict in conflict_file2. +Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit. [o|n|s|q]: o + + +Conflicts in conflict_file2 complete. + +You chose to: + Skip 0 conflicts + Acccept the original lines for 1 conflicts + Accept the new lines for 0 conflicts + +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]:''')); + expect(conflictFile.readAsStringSync(), + equals('hello\nwow a bunch of lines\nnew\nhi\n')); + expect(conflictFile2.readAsStringSync(), + equals('MoreConflicts\noriginal\nmultiple\nlines\nhi\n')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); + + testUsingContext('retry works', () async { + final File conflictFile = stagingDir.childFile('conflict_file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync( + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n<<<<<<<\noriginal2\n=======\nnew2\n>>>>>>>\n', + flush: true); + + final MigrateManifest manifest = MigrateManifest( + migrateRootDir: stagingDir, + migrateResult: MigrateResult( + mergeResults: [ + StringMergeResult.explicit( + localPath: 'merged_file', + mergedString: 'str', + hasConflict: false, + exitCode: 0, + ), + StringMergeResult.explicit( + localPath: 'conflict_file', + mergedString: + 'hello\nwow a bunch of lines\n<<<<<<<\noriginal\n=======\nnew\n>>>>>>>\nhi\n<<<<<<<\noriginal2\n=======\nnew2\n>>>>>>>\n', + 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: {}, + )); + manifest.writeFile(); + + expect(stagingDir.existsSync(), true); + final Future commandFuture = + createTestCommandRunner(command).run([ + 'resolve-conflicts', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + terminal.simulateStdin('n'); + terminal.simulateStdin('o'); + terminal.simulateStdin('r'); + // Retry with different choices + terminal.simulateStdin('o'); + terminal.simulateStdin('n'); + terminal.simulateStdin('y'); + + await commandFuture; + expect(logger.statusText, contains(''' +Cyan = Original lines. Green = New lines. + + 0 hello + 1 wow a bunch of lines + 2 <<<<<<< + 3 original + 4 ======= + 5 new + 6 >>>>>>> + 7 hi''')); + expect(logger.statusText, contains(''' +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]: r + + +Cyan = Original lines. Green = New lines.''')); + expect(logger.statusText, contains(''' +Conflict in conflict_file. +Accept the (o)riginal lines, (n)ew lines, or (s)kip and resolve the conflict manually? Or to exit the wizard, (q)uit. [o|n|s|q]: n + + +Conflicts in conflict_file complete. + +You chose to: + Skip 0 conflicts + Acccept the original lines for 1 conflicts + Accept the new lines for 1 conflicts + +Commit the changes to the working directory? (y)es, (n)o, (r)etry this file [y|n|r]:''')); + expect(conflictFile.readAsStringSync(), + equals('hello\nwow a bunch of lines\noriginal\nhi\nnew2\n')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); +} + +class FakeTerminal extends Fake implements Terminal { + factory FakeTerminal() { + return FakeTerminal._private( + stdio: FakeStdio(), + ); + } + + FakeTerminal._private({ + this.stdio, + }) : terminal = AnsiTerminal( + stdio: stdio, + ); + + final FakeStdio stdio; + final AnsiTerminal terminal; + + void simulateStdin(String line) { + stdio.simulateStdin(line); + } + + @override + set usesTerminalUi(bool value) => terminal.usesTerminalUi = value; + + @override + bool get usesTerminalUi => terminal.usesTerminalUi; + + @override + String clearScreen() => terminal.clearScreen(); + + @override + Future promptForCharInput(List acceptedCharacters, + {Logger logger, + String prompt, + int defaultChoiceIndex, + bool displayAcceptedCharacters = true}) => + terminal.promptForCharInput(acceptedCharacters, + logger: logger, + prompt: prompt, + defaultChoiceIndex: defaultChoiceIndex, + displayAcceptedCharacters: displayAcceptedCharacters); +} diff --git a/packages/flutter_migrate/test/status_test.dart b/packages/flutter_migrate/test/status_test.dart new file mode 100644 index 00000000000..7b6a8e39225 --- /dev/null +++ b/packages/flutter_migrate/test/status_test.dart @@ -0,0 +1,187 @@ +// 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:flutter_migrate/src/base/context.dart'; +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:flutter_migrate/src/commands/status.dart'; +import 'package:flutter_migrate/src/utils.dart'; +import 'package:process/process.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; +import 'src/test_flutter_command_runner.dart'; + +void main() { + FileSystem fileSystem; + BufferLogger logger; + ProcessManager processManager; + Directory appDir; + + setUp(() { + fileSystem = LocalFileSystem.test(signals: LocalSignals.instance); + appDir = fileSystem.systemTempDirectory.createTempSync('apptestdir'); + logger = BufferLogger.test(); + processManager = const LocalProcessManager(); + }); + + tearDown(() async { + tryToDelete(appDir); + }); + + testUsingContext('Status produces all outputs', () async { + final MigrateStatusCommand command = MigrateStatusCommand( + verbose: true, + logger: logger, + fileSystem: fileSystem, + processManager: processManager, + ); + final Directory stagingDir = + appDir.childDirectory(kDefaultMigrateStagingDirectoryName); + final File pubspecOriginal = appDir.childFile('pubspec.yaml'); + pubspecOriginal.createSync(); + pubspecOriginal.writeAsStringSync(''' +name: originalname +description: A new Flutter project. +version: 1.0.0+1 +environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: true''', flush: true); + + final File pubspecModified = stagingDir.childFile('pubspec.yaml'); + pubspecModified.createSync(recursive: true); + pubspecModified.writeAsStringSync(''' +name: newname +description: new description of the test project +version: 1.0.0+1 +environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: false + EXTRALINE''', flush: true); + + final File addedFile = stagingDir.childFile('added.file'); + addedFile.createSync(recursive: true); + addedFile.writeAsStringSync('new file contents'); + + final File manifestFile = stagingDir.childFile('.migrate_manifest'); + manifestFile.createSync(recursive: true); + manifestFile.writeAsStringSync(''' +merged_files: + - pubspec.yaml +conflict_files: +added_files: + - added.file +deleted_files: +'''); + + await createTestCommandRunner(command).run([ + 'status', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + expect(logger.statusText, contains(''' +Newly added file at added.file: + +new file contents''')); + expect(logger.statusText, contains(r''' +Added files: + - added.file +Modified files: + - pubspec.yaml + +All conflicts resolved. Review changes above and apply the migration with: + + $ flutter migrate apply +''')); + + expect(logger.statusText, contains(r''' +@@ -1,5 +1,5 @@ +-name: originalname +-description: A new Flutter project. ++name: newname ++description: new description of the test project + version: 1.0.0+1 + environment: + sdk: '>=2.18.0-58.0.dev <3.0.0' +@@ -10,4 +10,5 @@ dev_dependencies: + flutter_test: + sdk: flutter + flutter: +- uses-material-design: true +\ No newline at end of file ++ uses-material-design: false ++ EXTRALINE''')); + + // Add conflict file + final File conflictFile = + stagingDir.childDirectory('conflict').childFile('conflict.file'); + conflictFile.createSync(recursive: true); + conflictFile.writeAsStringSync(''' +line1 +<<<<<<< /conflcit/conflict.file +line2 +======= +linetwo +>>>>>>> /var/folders/md/gm0zgfcj07vcsj6jkh_mp_wh00ff02/T/flutter_tools.4Xdep8/generatedTargetTemplatetlN44S/conflict/conflict.file +line3 +''', flush: true); + final File conflictFileOriginal = + appDir.childDirectory('conflict').childFile('conflict.file'); + conflictFileOriginal.createSync(recursive: true); + conflictFileOriginal.writeAsStringSync(''' +line1 +line2 +line3 +''', flush: true); + + manifestFile.writeAsStringSync(''' +merged_files: + - pubspec.yaml +conflict_files: + - conflict/conflict.file +added_files: + - added.file +deleted_files: +'''); + + logger.clear(); + await createTestCommandRunner(command).run([ + 'status', + '--staging-directory=${stagingDir.path}', + '--project-directory=${appDir.path}', + '--flutter-subcommand', + ]); + + expect(logger.statusText, contains(''' +@@ -1,3 +1,7 @@ + line1 ++<<<<<<< /conflcit/conflict.file + line2 ++======= ++linetwo ++>>>>>>>''')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); +}