From 696110ba46a790189bc596da3033d480e581126d Mon Sep 17 00:00:00 2001 From: Gary Qian Date: Mon, 6 Jun 2022 17:38:06 -0700 Subject: [PATCH] Migrate apply (#102787) --- .../lib/src/commands/migrate.dart | 9 + .../lib/src/commands/migrate_apply.dart | 204 ++++++++++++++++ .../lib/src/migrate/migrate_update_locks.dart | 116 +++++++++ .../permeable/migrate_apply_test.dart | 231 ++++++++++++++++++ 4 files changed, 560 insertions(+) create mode 100644 packages/flutter_tools/lib/src/commands/migrate_apply.dart create mode 100644 packages/flutter_tools/lib/src/migrate/migrate_update_locks.dart create mode 100644 packages/flutter_tools/test/commands.shard/permeable/migrate_apply_test.dart diff --git a/packages/flutter_tools/lib/src/commands/migrate.dart b/packages/flutter_tools/lib/src/commands/migrate.dart index 2dcff4dc1727..d2c71fb6c79b 100644 --- a/packages/flutter_tools/lib/src/commands/migrate.dart +++ b/packages/flutter_tools/lib/src/commands/migrate.dart @@ -11,6 +11,7 @@ import '../base/terminal.dart'; import '../migrate/migrate_utils.dart'; import '../runner/flutter_command.dart'; import 'migrate_abandon.dart'; +import 'migrate_apply.dart'; import 'migrate_status.dart'; /// Base command for the migration tool. @@ -37,6 +38,14 @@ class MigrateCommand extends FlutterCommand { platform: platform, processManager: processManager )); + addSubcommand(MigrateApplyCommand( + verbose: verbose, + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + platform: platform, + processManager: processManager + )); } final Logger logger; diff --git a/packages/flutter_tools/lib/src/commands/migrate_apply.dart b/packages/flutter_tools/lib/src/commands/migrate_apply.dart new file mode 100644 index 000000000000..f02736ed42ab --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/migrate_apply.dart @@ -0,0 +1,204 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:process/process.dart'; + +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/platform.dart'; +import '../base/terminal.dart'; +import '../flutter_project_metadata.dart'; +import '../migrate/migrate_manifest.dart'; +import '../migrate/migrate_update_locks.dart'; +import '../migrate/migrate_utils.dart'; +import '../project.dart'; +import '../runner/flutter_command.dart'; +import '../version.dart'; +import 'migrate.dart'; + +/// Migrate subcommand that checks the migrate working directory for unresolved conflicts and +/// applies the staged changes to the project. +class MigrateApplyCommand extends FlutterCommand { + MigrateApplyCommand({ + bool verbose = false, + required this.logger, + required this.fileSystem, + required this.terminal, + required Platform platform, + required ProcessManager processManager, + }) : _verbose = verbose, + migrateUtils = MigrateUtils( + logger: logger, + fileSystem: fileSystem, + platform: platform, + processManager: processManager, + ) { + requiresPubspecYaml(); + 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.', + ); + } + + 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 + String get category => FlutterCommandCategory.project; + + @override + Future> get requiredArtifacts async => const {}; + + @override + Future runCommand() async { + final String? projectDirectory = stringArg('project-directory'); + final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory(logger: logger, fileSystem: fileSystem); + final FlutterProject project = projectDirectory == null + ? FlutterProject.current() + : flutterProjectFactory.fromDirectory(fileSystem.directory(projectDirectory)); + + 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); + return const FlutterCommandResult(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('flutter migrate start', logger); + return const FlutterCommandResult(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('flutter migrate resolve-conflicts', logger); + return const FlutterCommandResult(ExitStatus.fail); + } + + if (await hasUncommittedChanges(project.directory.path, logger, migrateUtils) && !force) { + return const FlutterCommandResult(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 = FlutterProject.current().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 FlutterVersion version = FlutterVersion(workingDirectory: project.directory.absolute.path); + + final String currentGitHash = version.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 FlutterCommandResult(ExitStatus.success); + } +} diff --git a/packages/flutter_tools/lib/src/migrate/migrate_update_locks.dart b/packages/flutter_tools/lib/src/migrate/migrate_update_locks.dart new file mode 100644 index 000000000000..665fc4f64b6f --- /dev/null +++ b/packages/flutter_tools/lib/src/migrate/migrate_update_locks.dart @@ -0,0 +1,116 @@ +// 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/terminal.dart'; +import '../project.dart'; +import 'migrate_utils.dart'; + +/// Checks if the project uses pubspec dependency locking and prompts if +/// the pub upgrade should be run. +Future updatePubspecDependencies( + FlutterProject flutterProject, + MigrateUtils migrateUtils, + Logger logger, + Terminal terminal +) async { + final File pubspecFile = flutterProject.directory.childFile('pubspec.yaml'); + if (!pubspecFile.existsSync()) { + return; + } + if (!pubspecFile.readAsStringSync().contains('# THIS LINE IS AUTOGENERATED')) { + return; + } + logger.printStatus('\nDart dependency locking detected in pubspec.yaml.'); + terminal.usesTerminalUi = true; + String selection = 'y'; + selection = await terminal.promptForCharInput( + ['y', 'n'], + logger: logger, + prompt: 'Do you want the tool to run `flutter pub upgrade --major-versions`? (y)es, (n)o', + defaultChoiceIndex: 1, + ); + if (selection == 'y') { + // Runs `flutter pub upgrade --major-versions` + await migrateUtils.flutterPubUpgrade(flutterProject.directory.path); + } +} + +/// Checks if gradle dependency locking is used and prompts the developer to +/// remove and back up the gradle dependency lockfile. +Future updateGradleDependencyLocking( + FlutterProject flutterProject, + MigrateUtils migrateUtils, + Logger logger, + Terminal terminal, + bool verbose, + FileSystem fileSystem +) async { + final Directory androidDir = flutterProject.directory.childDirectory('android'); + if (!androidDir.existsSync()) { + return; + } + final List androidFiles = androidDir.listSync(); + final List lockfiles = []; + final List backedUpFilePaths = []; + for (final FileSystemEntity entity in androidFiles) { + if (entity is! File) { + continue; + } + final File file = entity.absolute; + // Don't re-handle backed up lockfiles. + if (file.path.contains('_backup_')) { + continue; + } + try { + // lockfiles generated by gradle start with this prefix. + if (file.readAsStringSync().startsWith( + '# This is a Gradle generated file for dependency locking.\n# ' + 'Manual edits can break the build and are not advised.\n# This ' + 'file is expected to be part of source control.')) { + lockfiles.add(file); + } + } on FileSystemException { + if (verbose) { + logger.printStatus('Unable to check ${file.path}'); + } + } + } + if (lockfiles.isNotEmpty) { + logger.printStatus('\nGradle dependency locking detected.'); + logger.printStatus('Flutter can backup the lockfiles and regenerate updated ' + 'lockfiles.'); + terminal.usesTerminalUi = true; + String selection = 'y'; + selection = await terminal.promptForCharInput( + ['y', 'n'], + logger: logger, + prompt: 'Do you want the tool to update locked dependencies? (y)es, (n)o', + defaultChoiceIndex: 1, + ); + if (selection == 'y') { + for (final File file in lockfiles) { + int counter = 0; + while (true) { + final String newPath = '${file.absolute.path}_backup_$counter'; + if (!fileSystem.file(newPath).existsSync()) { + file.renameSync(newPath); + backedUpFilePaths.add(newPath); + break; + } else { + counter++; + } + } + } + // Runs `./gradlew tasks`in the project's android directory. + await migrateUtils.gradlewTasks(flutterProject.directory.childDirectory('android').path); + logger.printStatus('Old lockfiles renamed to:'); + for (final String path in backedUpFilePaths) { + logger.printStatus(path, color: TerminalColor.grey, indent: 2); + } + } + } +} diff --git a/packages/flutter_tools/test/commands.shard/permeable/migrate_apply_test.dart b/packages/flutter_tools/test/commands.shard/permeable/migrate_apply_test.dart new file mode 100644 index 000000000000..4e1ee2dc1587 --- /dev/null +++ b/packages/flutter_tools/test/commands.shard/permeable/migrate_apply_test.dart @@ -0,0 +1,231 @@ +// 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_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/terminal.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/commands/migrate.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/migrate/migrate_utils.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; +import '../../src/test_flutter_command_runner.dart'; + +void main() { + FileSystem fileSystem; + BufferLogger logger; + Platform platform; + Terminal terminal; + ProcessManager processManager; + Directory appDir; + + setUp(() { + fileSystem = globals.localFileSystem; + appDir = fileSystem.systemTempDirectory.createTempSync('apptestdir'); + logger = BufferLogger.test(); + platform = FakePlatform(); + terminal = Terminal.test(); + processManager = globals.processManager; + }); + + setUpAll(() { + Cache.disableLocking(); + }); + + tearDown(() async { + tryToDelete(appDir); + }); + + testUsingContext('Apply produces all outputs', () async { + final MigrateCommand command = MigrateCommand( + verbose: true, + logger: logger, + fileSystem: fileSystem, + terminal: terminal, + platform: platform, + 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( + [ + 'migrate', + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + ] + ); + 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( + [ + 'migrate', + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + ] + ); + 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( + [ + 'migrate', + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + ] + ); + 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( + [ + 'migrate', + 'apply', + '--staging-directory=${workingDir.path}', + '--project-directory=${appDir.path}', + ] + ); + 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}', + ] + ); + 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, + Platform: () => platform, + }); +}