Add "flutter downgrade" command (#50506)

diff --git a/packages/flutter_tools/test/commands.shard/hermetic/downgrade_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/downgrade_test.dart
new file mode 100644
index 0000000..db627c2
--- /dev/null
+++ b/packages/flutter_tools/test/commands.shard/hermetic/downgrade_test.dart
@@ -0,0 +1,249 @@
+// 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/file.dart';
+import 'package:file/memory.dart';
+import 'package:flutter_tools/src/base/io.dart';
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/base/terminal.dart';
+import 'package:flutter_tools/src/cache.dart';
+import 'package:flutter_tools/src/commands/downgrade.dart';
+import 'package:flutter_tools/src/persistent_tool_state.dart';
+import 'package:flutter_tools/src/version.dart';
+import 'package:mockito/mockito.dart';
+
+import '../../src/common.dart';
+import '../../src/context.dart';
+import '../../src/mocks.dart';
+
+void main() {
+  FileSystem fileSystem;
+  BufferLogger bufferLogger;
+  AnsiTerminal terminal;
+  ProcessManager processManager;
+  MockStdio mockStdio;
+  FlutterVersion flutterVersion;
+
+  setUpAll(() {
+    Cache.disableLocking();
+  });
+
+  tearDownAll(() {
+    Cache.enableLocking();
+  });
+
+  setUp(() {
+    flutterVersion = MockFlutterVersion();
+    mockStdio = MockStdio();
+    processManager = FakeProcessManager.any();
+    terminal = MockTerminal();
+    fileSystem = MemoryFileSystem.test();
+    bufferLogger = BufferLogger(terminal: terminal, outputPreferences: OutputPreferences.test());
+  });
+
+  testUsingContext('Downgrade exits on unknown channel', () async {
+    fileSystem.currentDirectory.childFile('.flutter_tool_state')
+      .writeAsStringSync('{"last-active-master-version":"invalid"}');
+    final DowngradeCommand command = DowngradeCommand(
+      persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger),
+      processManager: processManager,
+      terminal: terminal,
+      stdio: mockStdio,
+      flutterVersion: flutterVersion,
+      logger: bufferLogger,
+    );
+    applyMocksToCommand(command);
+
+    expect(createTestCommandRunner(command).run(const <String>['downgrade']),
+      throwsToolExit(message: 'Flutter is not currently on a known channel.'));
+  });
+
+  testUsingContext('Downgrade exits on no recorded version', () async {
+    when(flutterVersion.channel).thenReturn('dev');
+    fileSystem.currentDirectory.childFile('.flutter_tool_state')
+      .writeAsStringSync('{"last-active-master-version":"abcd"}');
+    final DowngradeCommand command = DowngradeCommand(
+      persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger),
+      processManager: FakeProcessManager.list(<FakeCommand>[
+        const FakeCommand(
+          command: <String>[
+            'git', 'describe', '--tags', 'abcd'
+          ],
+          exitCode: 0,
+          stdout: 'v1.2.3'
+        )
+      ]),
+      terminal: terminal,
+      stdio: mockStdio,
+      flutterVersion: flutterVersion,
+      logger: bufferLogger,
+    );
+    applyMocksToCommand(command);
+
+    expect(createTestCommandRunner(command).run(const <String>['downgrade']),
+      throwsToolExit(message:
+        'There is no previously recorded version for channel "dev".\n'
+        'Channel "master" was previously on: v1.2.3.'
+      ),
+    );
+  });
+
+  testUsingContext('Downgrade exits on unknown recorded version', () async {
+    when(flutterVersion.channel).thenReturn('master');
+    fileSystem.currentDirectory.childFile('.flutter_tool_state')
+      .writeAsStringSync('{"last-active-master-version":"invalid"}');
+    final DowngradeCommand command = DowngradeCommand(
+      persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger),
+      processManager: FakeProcessManager.list(<FakeCommand>[
+        const FakeCommand(
+          command: <String>[
+            'git', 'describe', '--tags', 'invalid'
+          ],
+          exitCode: 1,
+        )
+      ]),
+      terminal: terminal,
+      stdio: mockStdio,
+      flutterVersion: flutterVersion,
+      logger: bufferLogger,
+    );
+    applyMocksToCommand(command);
+
+    expect(createTestCommandRunner(command).run(const <String>['downgrade']),
+      throwsToolExit(message: 'Failed to parse version for downgrade'));
+  });
+
+   testUsingContext('Downgrade prompts for user input when terminal is attached - y', () async {
+    when(flutterVersion.channel).thenReturn('master');
+    when(mockStdio.hasTerminal).thenReturn(true);
+    fileSystem.currentDirectory.childFile('.flutter_tool_state')
+      .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}');
+    final DowngradeCommand command = DowngradeCommand(
+      persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger),
+      processManager: processManager,
+      terminal: terminal,
+      stdio: mockStdio,
+      flutterVersion: flutterVersion,
+      logger: bufferLogger,
+    );
+    applyMocksToCommand(command);
+
+    when(terminal.promptForCharInput(
+      const <String>['y', 'n'],
+      prompt: anyNamed('prompt'),
+      logger: anyNamed('logger'),
+    )).thenAnswer((Invocation invocation) async => 'y');
+
+    await createTestCommandRunner(command).run(const <String>['downgrade']);
+
+    verify(terminal.promptForCharInput(
+      const <String>['y', 'n'],
+      prompt: anyNamed('prompt'),
+      logger: anyNamed('logger'),
+    )).called(1);
+    expect(bufferLogger.statusText, contains('Success'));
+  });
+
+   testUsingContext('Downgrade prompts for user input when terminal is attached - n', () async {
+    when(flutterVersion.channel).thenReturn('master');
+    when(mockStdio.hasTerminal).thenReturn(true);
+    fileSystem.currentDirectory.childFile('.flutter_tool_state')
+      .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}');
+    final DowngradeCommand command = DowngradeCommand(
+      persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger),
+      processManager: processManager,
+      terminal: terminal,
+      stdio: mockStdio,
+      flutterVersion: flutterVersion,
+      logger: bufferLogger,
+    );
+    applyMocksToCommand(command);
+
+    when(terminal.promptForCharInput(
+      const <String>['y', 'n'],
+      prompt: anyNamed('prompt'),
+      logger: anyNamed('logger'),
+    )).thenAnswer((Invocation invocation) async => 'n');
+
+    await createTestCommandRunner(command).run(const <String>['downgrade']);
+
+    verify(terminal.promptForCharInput(
+      const <String>['y', 'n'],
+      prompt: anyNamed('prompt'),
+      logger: anyNamed('logger'),
+    )).called(1);
+    expect(bufferLogger.statusText, isNot(contains('Success')));
+  });
+
+  testUsingContext('Downgrade does not prompt when there is no terminal', () async {
+    when(flutterVersion.channel).thenReturn('master');
+    when(mockStdio.hasTerminal).thenReturn(false);
+    fileSystem.currentDirectory.childFile('.flutter_tool_state')
+      .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}');
+    final DowngradeCommand command = DowngradeCommand(
+      persistentToolState: PersistentToolState.test(
+        directory: fileSystem.currentDirectory,
+        logger: bufferLogger,
+      ),
+      processManager: processManager,
+      terminal: terminal,
+      stdio: mockStdio,
+      flutterVersion: flutterVersion,
+      logger: bufferLogger,
+    );
+    applyMocksToCommand(command);
+
+    await createTestCommandRunner(command).run(const <String>['downgrade']);
+
+    verifyNever(terminal.promptForCharInput(
+      const <String>['y', 'n'],
+      prompt: anyNamed('prompt'),
+      logger: anyNamed('logger'),
+    ));
+    expect(bufferLogger.statusText, contains('Success'));
+  });
+
+  testUsingContext('Downgrade performs correct git commands', () async {
+    when(flutterVersion.channel).thenReturn('master');
+    when(mockStdio.hasTerminal).thenReturn(false);
+    fileSystem.currentDirectory.childFile('.flutter_tool_state')
+      .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}');
+    final DowngradeCommand command = DowngradeCommand(
+      persistentToolState: PersistentToolState.test(
+        directory: fileSystem.currentDirectory,
+        logger: bufferLogger,
+      ),
+      processManager: FakeProcessManager.list(<FakeCommand>[
+        const FakeCommand(
+          command: <String>[
+            'git', 'describe', '--tags', 'g6b00b5e88'
+          ],
+          stdout: 'v1.2.3',
+        ),
+        const FakeCommand(
+          command: <String>[
+            'git', 'reset', '--hard', 'g6b00b5e88'
+          ],
+        ),
+        const FakeCommand(
+          command: <String>[
+            'git', 'checkout', 'master', '--'
+          ]
+        ),
+      ]),
+      terminal: terminal,
+      stdio: mockStdio,
+      flutterVersion: flutterVersion,
+      logger: bufferLogger,
+    );
+    applyMocksToCommand(command);
+
+    await createTestCommandRunner(command).run(const <String>['downgrade']);
+
+    expect(bufferLogger.statusText, contains('Success'));
+  });
+}
+
+class MockTerminal extends Mock implements AnsiTerminal {}
+class MockStdio extends Mock implements Stdio {}
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart
index 98922e0..74596c3 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart
@@ -8,11 +8,13 @@
 
 import 'package:flutter_tools/src/base/common.dart';
 import 'package:flutter_tools/src/base/io.dart';
+import 'package:flutter_tools/src/base/terminal.dart';
 import 'package:flutter_tools/src/cache.dart';
 import 'package:flutter_tools/src/commands/version.dart';
 import 'package:flutter_tools/src/version.dart';
 import 'package:mockito/mockito.dart';
 import 'package:process/process.dart';
+import 'package:flutter_tools/src/globals.dart' as globals;
 
 import '../../src/common.dart';
 import '../../src/context.dart';
@@ -20,10 +22,18 @@
 
 void main() {
   group('version', () {
+    MockStdio mockStdio;
+
     setUpAll(() {
       Cache.disableLocking();
     });
 
+    setUp(() {
+      mockStdio = MockStdio();
+      when(mockStdio.stdinHasTerminal).thenReturn(false);
+      when(mockStdio.hasTerminal).thenReturn(false);
+    });
+
     testUsingContext('version ls', () async {
       final VersionCommand command = VersionCommand();
       await createTestCommandRunner(command).run(<String>[
@@ -33,11 +43,18 @@
       expect(testLogger.statusText, equals('v10.0.0\r\nv20.0.0\n'));
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(),
+      Stdio: () => mockStdio,
     });
 
-    testUsingContext('version switch', () async {
+    testUsingContext('version switch prompt is accepted', () async {
+      when(mockStdio.stdinHasTerminal).thenReturn(true);
       const String version = '10.0.0';
       final VersionCommand command = VersionCommand();
+      when(globals.terminal.promptForCharInput(<String>['y', 'n'],
+        logger: anyNamed('logger'),
+        prompt: 'Are you sure you want to proceed?')
+      ).thenAnswer((Invocation invocation) async => 'y');
+
       await createTestCommandRunner(command).run(<String>[
         'version',
         '--no-pub',
@@ -46,6 +63,29 @@
       expect(testLogger.statusText, contains('Switching Flutter to version $version'));
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(),
+      Stdio: () => mockStdio,
+      AnsiTerminal: () => MockTerminal(),
+    });
+
+    testUsingContext('version switch prompt is declined', () async {
+      when(mockStdio.stdinHasTerminal).thenReturn(true);
+      const String version = '10.0.0';
+      final VersionCommand command = VersionCommand();
+      when(globals.terminal.promptForCharInput(<String>['y', 'n'],
+        logger: anyNamed('logger'),
+        prompt: 'Are you sure you want to proceed?')
+      ).thenAnswer((Invocation invocation) async => 'n');
+
+      await createTestCommandRunner(command).run(<String>[
+        'version',
+        '--no-pub',
+        version,
+      ]);
+      expect(testLogger.statusText, isNot(contains('Switching Flutter to version $version')));
+    }, overrides: <Type, Generator>{
+      ProcessManager: () => MockProcessManager(),
+      Stdio: () => mockStdio,
+      AnsiTerminal: () => MockTerminal(),
     });
 
     testUsingContext('version switch, latest commit query fails', () async {
@@ -59,6 +99,7 @@
       expect(testLogger.errorText, contains('git failed'));
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(latestCommitFails: true),
+      Stdio: () => mockStdio,
     });
 
     testUsingContext('latest commit is parsable when query fails', () {
@@ -69,6 +110,7 @@
       );
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(latestCommitFails: true),
+      Stdio: () => mockStdio,
     });
 
     testUsingContext('switch to not supported version without force', () async {
@@ -82,6 +124,7 @@
       expect(testLogger.errorText, contains('Version command is not supported in'));
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(),
+      Stdio: () => mockStdio,
     });
 
     testUsingContext('switch to not supported version with force', () async {
@@ -96,6 +139,7 @@
       expect(testLogger.statusText, contains('Switching Flutter to version $version with force'));
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(),
+      Stdio: () => mockStdio,
     });
 
     testUsingContext('tool exit on confusing version', () async {
@@ -111,6 +155,7 @@
       );
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(),
+      Stdio: () => mockStdio,
     });
 
     testUsingContext("exit tool if can't get the tags", () async {
@@ -123,10 +168,13 @@
       }
     }, overrides: <Type, Generator>{
       ProcessManager: () => MockProcessManager(failGitTag: true),
+      Stdio: () => mockStdio,
     });
   });
 }
 
+class MockTerminal extends Mock implements AnsiTerminal {}
+class MockStdio extends Mock implements Stdio {}
 class MockProcessManager extends Mock implements ProcessManager {
   MockProcessManager({
     this.failGitTag = false,
diff --git a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart
index 1d71094..f5ac6fb 100644
--- a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart
+++ b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart
@@ -57,10 +57,11 @@
 
     testUsingContext('throws on unknown tag, official branch,  noforce', () async {
       final Future<FlutterCommandResult> result = fakeCommandRunner.runCommand(
-        false,
-        false,
-        const GitTagVersion.unknown(),
-        flutterVersion,
+        force: false,
+        continueFlow: false,
+        testFlow: false,
+        gitTagVersion: const GitTagVersion.unknown(),
+        flutterVersion: flutterVersion,
       );
       expect(result, throwsToolExit());
     }, overrides: <Type, Generator>{
@@ -69,10 +70,11 @@
 
     testUsingContext('does not throw on unknown tag, official branch, force', () async {
       final Future<FlutterCommandResult> result = fakeCommandRunner.runCommand(
-        true,
-        false,
-        const GitTagVersion.unknown(),
-        flutterVersion,
+        force: true,
+        continueFlow: false,
+        testFlow: false,
+        gitTagVersion: const GitTagVersion.unknown(),
+        flutterVersion: flutterVersion,
       );
       expect(await result, FlutterCommandResult.success());
     }, overrides: <Type, Generator>{
@@ -83,10 +85,11 @@
     testUsingContext('throws tool exit with uncommitted changes', () async {
       fakeCommandRunner.willHaveUncomittedChanges = true;
       final Future<FlutterCommandResult> result = fakeCommandRunner.runCommand(
-        false,
-        false,
-        gitTagVersion,
-        flutterVersion,
+        force: false,
+        continueFlow: false,
+        testFlow: false,
+        gitTagVersion: gitTagVersion,
+        flutterVersion: flutterVersion,
       );
       expect(result, throwsToolExit());
     }, overrides: <Type, Generator>{
@@ -97,10 +100,11 @@
       fakeCommandRunner.willHaveUncomittedChanges = true;
 
       final Future<FlutterCommandResult> result = fakeCommandRunner.runCommand(
-        true,
-        false,
-        gitTagVersion,
-        flutterVersion,
+        force: true,
+        continueFlow: false,
+        testFlow: false,
+        gitTagVersion: gitTagVersion,
+        flutterVersion: flutterVersion,
       );
       expect(await result, FlutterCommandResult.success());
     }, overrides: <Type, Generator>{
@@ -110,10 +114,11 @@
 
     testUsingContext("Doesn't throw on known tag, dev branch, no force", () async {
       final Future<FlutterCommandResult> result = fakeCommandRunner.runCommand(
-        false,
-        false,
-        gitTagVersion,
-        flutterVersion,
+        force: false,
+        continueFlow: false,
+        testFlow: false,
+        gitTagVersion: gitTagVersion,
+        flutterVersion: flutterVersion,
       );
       expect(await result, FlutterCommandResult.success());
     }, overrides: <Type, Generator>{
@@ -124,10 +129,11 @@
     testUsingContext("Doesn't continue on known tag, dev branch, no force, already up-to-date", () async {
       fakeCommandRunner.alreadyUpToDate = true;
       final Future<FlutterCommandResult> result = fakeCommandRunner.runCommand(
-        false,
-        false,
-        gitTagVersion,
-        flutterVersion,
+        force: false,
+        continueFlow: false,
+        testFlow: false,
+        gitTagVersion: gitTagVersion,
+        flutterVersion: flutterVersion,
       );
       expect(await result, FlutterCommandResult.success());
       verifyNever(globals.processManager.start(