Clean Xcode workspace during flutter clean (#38992)

diff --git a/packages/flutter_tools/lib/src/commands/clean.dart b/packages/flutter_tools/lib/src/commands/clean.dart
index 7121134..5603c28 100644
--- a/packages/flutter_tools/lib/src/commands/clean.dart
+++ b/packages/flutter_tools/lib/src/commands/clean.dart
@@ -4,11 +4,15 @@
 
 import 'dart:async';
 
-import '../base/common.dart';
+import 'package:meta/meta.dart';
+
 import '../base/file_system.dart';
+import '../base/logger.dart';
 import '../base/platform.dart';
 import '../build_info.dart';
 import '../globals.dart';
+import '../ios/xcodeproj.dart';
+import '../macos/xcode.dart';
 import '../project.dart';
 import '../runner/flutter_command.dart';
 
@@ -28,38 +32,70 @@
 
   @override
   Future<FlutterCommandResult> runCommand() async {
-    final Directory buildDir = fs.directory(getBuildDirectory());
-    _deleteFile(buildDir);
-
+    // Clean Xcode to remove intermediate DerivedData artifacts.
+    // Do this before removing ephemeral directory, which would delete the xcworkspace.
     final FlutterProject flutterProject = FlutterProject.current();
-    _deleteFile(flutterProject.dartTool);
+    if (xcode.isInstalledAndMeetsVersionCheck) {
+      await _cleanXcode(flutterProject.ios);
+      await _cleanXcode(flutterProject.macos);
+    }
+
+    final Directory buildDir = fs.directory(getBuildDirectory());
+    deleteFile(buildDir);
+
+    deleteFile(flutterProject.dartTool);
 
     final Directory androidEphemeralDirectory = flutterProject.android.ephemeralDirectory;
-    _deleteFile(androidEphemeralDirectory);
+    deleteFile(androidEphemeralDirectory);
 
     final Directory iosEphemeralDirectory = flutterProject.ios.ephemeralDirectory;
-    _deleteFile(iosEphemeralDirectory);
+    deleteFile(iosEphemeralDirectory);
+
+    final Directory macosEphemeralDirectory = flutterProject.macos.ephemeralDirectory;
+    deleteFile(macosEphemeralDirectory);
 
     return const FlutterCommandResult(ExitStatus.success);
   }
 
-  void _deleteFile(FileSystemEntity file) {
-    final String path = file.path;
-    printStatus("Deleting '$path${fs.path.separator}'.");
-    if (file.existsSync()) {
-      try {
-        file.deleteSync(recursive: true);
-      } on FileSystemException catch (error) {
-        if (platform.isWindows) {
-          printError(
-            'Failed to remove $path. '
+  Future<void> _cleanXcode(XcodeBasedProject xcodeProject) async {
+    if (!xcodeProject.existsSync()) {
+      return;
+    }
+    final Status xcodeStatus = logger.startProgress('Cleaning Xcode workspace...', timeout: timeoutConfiguration.slowOperation);
+    try {
+      final Directory xcodeWorkspace = xcodeProject.xcodeWorkspace;
+      final XcodeProjectInfo projectInfo = await xcodeProjectInterpreter.getInfo(xcodeWorkspace.parent.path);
+      for (String scheme in projectInfo.schemes) {
+        xcodeProjectInterpreter.cleanWorkspace(xcodeWorkspace.path, scheme);
+      }
+    } catch (error) {
+      printTrace('Could not clean Xcode workspace: $error');
+    } finally {
+      xcodeStatus?.stop();
+    }
+  }
+
+  @visibleForTesting
+  void deleteFile(FileSystemEntity file) {
+    if (!file.existsSync()) {
+      return;
+    }
+    final Status deletionStatus = logger.startProgress('Deleting ${file.basename}...', timeout: timeoutConfiguration.fastOperation);
+    try {
+      file.deleteSync(recursive: true);
+    } on FileSystemException catch (error) {
+      final String path = file.path;
+      if (platform.isWindows) {
+        printError(
+          'Failed to remove $path. '
             'A program may still be using a file in the directory or the directory itself. '
             'To find and stop such a program, see: '
             'https://superuser.com/questions/1333118/cant-delete-empty-folder-because-it-is-used');
-        }
-        throwToolExit(error.toString());
+      } else {
+        printError('Failed to remove $path: $error');
       }
+    } finally {
+      deletionStatus.stop();
     }
   }
 }
-
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index 12d7c8e..834e03f 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -15,7 +15,6 @@
 import '../device.dart';
 import '../features.dart';
 import '../globals.dart';
-import '../macos/xcode.dart';
 import '../project.dart';
 import '../reporting/reporting.dart';
 import '../resident_runner.dart';
@@ -237,19 +236,6 @@
   }
 
   @override
-  void printNoConnectedDevices() {
-    super.printNoConnectedDevices();
-    if (getCurrentHostPlatform() == HostPlatform.darwin_x64 &&
-        xcode.isInstalledAndMeetsVersionCheck) {
-      printStatus('');
-      printStatus("Run 'flutter emulators' to list and start any available device emulators.");
-      printStatus('');
-      printStatus('If you expected your device to be detected, please run "flutter doctor" to diagnose');
-      printStatus('potential issues, or visit https://flutter.dev/setup/ for troubleshooting tips.');
-    }
-  }
-
-  @override
   bool get shouldRunPub {
     // If we are running with a prebuilt application, do not run pub.
     if (runningWithPrebuiltApplication)
diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
index bd9b6d1..ccd5c02 100644
--- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart
+++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
@@ -266,6 +266,18 @@
     }
   }
 
+  void cleanWorkspace(String workspacePath, String scheme) {
+    runSync(<String>[
+      _executable,
+      '-workspace',
+      workspacePath,
+      '-scheme',
+      scheme,
+      '-quiet',
+      'clean'
+    ], workingDirectory: fs.currentDirectory.path);
+  }
+
   Future<XcodeProjectInfo> getInfo(String projectPath) async {
     final RunResult result = await runCheckedAsync(<String>[
       _executable, '-list',
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart
index 7b3b915..6fea9ad 100644
--- a/packages/flutter_tools/lib/src/macos/xcode.dart
+++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -7,6 +7,7 @@
 import '../base/context.dart';
 import '../base/file_system.dart';
 import '../base/io.dart';
+import '../base/platform.dart';
 import '../base/process.dart';
 import '../base/process_manager.dart';
 import '../ios/xcodeproj.dart';
@@ -17,7 +18,7 @@
 Xcode get xcode => context.get<Xcode>();
 
 class Xcode {
-  bool get isInstalledAndMeetsVersionCheck => isInstalled && isVersionSatisfactory;
+  bool get isInstalledAndMeetsVersionCheck => platform.isMacOS && isInstalled && isVersionSatisfactory;
 
   String _xcodeSelectPath;
   String get xcodeSelectPath {
diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart
index dd23247..c82a00d 100644
--- a/packages/flutter_tools/lib/src/runner/flutter_command.dart
+++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart
@@ -555,10 +555,6 @@
     return deviceList.single;
   }
 
-  void printNoConnectedDevices() {
-    printStatus(userMessages.flutterNoConnectedDevices);
-  }
-
   @protected
   @mustCallSuper
   Future<void> validateCommand() async {
diff --git a/packages/flutter_tools/test/general.shard/commands/clean_test.dart b/packages/flutter_tools/test/general.shard/commands/clean_test.dart
index 76eee94..8486081 100644
--- a/packages/flutter_tools/test/general.shard/commands/clean_test.dart
+++ b/packages/flutter_tools/test/general.shard/commands/clean_test.dart
@@ -2,89 +2,95 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'package:flutter_tools/src/base/common.dart';
-import 'package:flutter_tools/src/base/config.dart';
+import 'package:file/memory.dart';
 import 'package:flutter_tools/src/base/context.dart';
 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/commands/clean.dart';
+import 'package:flutter_tools/src/ios/xcodeproj.dart';
+import 'package:flutter_tools/src/macos/xcode.dart';
+import 'package:flutter_tools/src/project.dart';
 import 'package:mockito/mockito.dart';
 
 import '../../src/common.dart';
 import '../../src/context.dart';
 
 void main() {
-  MockFileSystem mockFileSystem;
-  MockDirectory currentDirectory;
-  MockDirectory exampleDirectory;
-  MockDirectory buildDirectory;
-  MockDirectory dartToolDirectory;
-  MockDirectory androidEphemeralDirectory;
-  MockDirectory iosEphemeralDirectory;
-  MockFile pubspec;
-  MockFile examplePubspec;
+  MemoryFileSystem fs;
   MockPlatform windowsPlatform;
+  MockXcode mockXcode;
+  FlutterProject projectUnderTest;
+  MockXcodeProjectInterpreter mockXcodeProjectInterpreter;
+  Directory buildDirectory;
 
   setUp(() {
-    mockFileSystem = MockFileSystem();
-    currentDirectory = MockDirectory();
-    exampleDirectory = MockDirectory();
-    buildDirectory = MockDirectory();
-    dartToolDirectory = MockDirectory();
-    androidEphemeralDirectory = MockDirectory();
-    iosEphemeralDirectory = MockDirectory();
-    pubspec = MockFile();
-    examplePubspec = MockFile();
+    fs = MemoryFileSystem();
+    mockXcodeProjectInterpreter = MockXcodeProjectInterpreter();
     windowsPlatform = MockPlatform();
-    when(mockFileSystem.currentDirectory).thenReturn(currentDirectory);
-    when(currentDirectory.childDirectory('example')).thenReturn(exampleDirectory);
-    when(currentDirectory.childFile('pubspec.yaml')).thenReturn(pubspec);
-    when(pubspec.path).thenReturn('/test/pubspec.yaml');
-    when(exampleDirectory.childFile('pubspec.yaml')).thenReturn(examplePubspec);
-    when(currentDirectory.childDirectory('.dart_tool')).thenReturn(dartToolDirectory);
-    when(currentDirectory.childDirectory('.android')).thenReturn(androidEphemeralDirectory);
-    when(currentDirectory.childDirectory('.ios')).thenReturn(iosEphemeralDirectory);
-    when(examplePubspec.path).thenReturn('/test/example/pubspec.yaml');
-    when(mockFileSystem.isFileSync('/test/pubspec.yaml')).thenReturn(false);
-    when(mockFileSystem.isFileSync('/test/example/pubspec.yaml')).thenReturn(false);
-    when(mockFileSystem.directory('build')).thenReturn(buildDirectory);
-    when(mockFileSystem.path).thenReturn(fs.path);
-    when(buildDirectory.existsSync()).thenReturn(true);
-    when(dartToolDirectory.existsSync()).thenReturn(true);
-    when(androidEphemeralDirectory.existsSync()).thenReturn(true);
-    when(iosEphemeralDirectory.existsSync()).thenReturn(true);
-    when(windowsPlatform.isWindows).thenReturn(true);
+    mockXcode = MockXcode();
+
+    final Directory currentDirectory = fs.currentDirectory;
+    buildDirectory = currentDirectory.childDirectory('build');
+    buildDirectory.createSync(recursive: true);
+
+    projectUnderTest = FlutterProject.fromDirectory(currentDirectory);
+    projectUnderTest.ios.xcodeWorkspace.createSync(recursive: true);
+    projectUnderTest.macos.xcodeWorkspace.createSync(recursive: true);
+
+    projectUnderTest.dartTool.createSync(recursive: true);
+    projectUnderTest.android.ephemeralDirectory.createSync(recursive: true);
+    projectUnderTest.ios.ephemeralDirectory.createSync(recursive: true);
+    projectUnderTest.macos.ephemeralDirectory.createSync(recursive: true);
   });
 
   group(CleanCommand, () {
-    testUsingContext('removes build and .dart_tool and ephemeral directories', () async {
+    testUsingContext('removes build and .dart_tool and ephemeral directories, cleans Xcode', () async {
+      when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
       await CleanCommand().runCommand();
-      verify(buildDirectory.deleteSync(recursive: true)).called(1);
-      verify(dartToolDirectory.deleteSync(recursive: true)).called(1);
-      verify(androidEphemeralDirectory.deleteSync(recursive: true)).called(1);
-      verify(iosEphemeralDirectory.deleteSync(recursive: true)).called(1);
+
+      expect(buildDirectory.existsSync(), isFalse);
+      expect(projectUnderTest.dartTool.existsSync(), isFalse);
+      expect(projectUnderTest.android.ephemeralDirectory.existsSync(), isFalse);
+      expect(projectUnderTest.ios.ephemeralDirectory.existsSync(), isFalse);
+      expect(projectUnderTest.macos.ephemeralDirectory.existsSync(), isFalse);
+
+      verify(xcodeProjectInterpreter.cleanWorkspace(any, 'Runner')).called(2);
     }, overrides: <Type, Generator>{
-      Config: () => null,
-      FileSystem: () => mockFileSystem,
+      FileSystem: () => fs,
+      Xcode: () => mockXcode,
+      FileSystem: () => fs,
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
     });
 
     testUsingContext('prints a helpful error message on Windows', () async {
+      when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(false);
+      when(windowsPlatform.isWindows).thenReturn(true);
+
+      final MockFile mockFile = MockFile();
+      when(mockFile.existsSync()).thenReturn(true);
+
       final BufferLogger logger = context.get<Logger>();
-      when(buildDirectory.deleteSync(recursive: true)).thenThrow(
-          const FileSystemException('Deletion failed'));
-      expect(() async => await CleanCommand().runCommand(), throwsA(isInstanceOf<ToolExit>()));
+      when(mockFile.deleteSync(recursive: true)).thenThrow(const FileSystemException('Deletion failed'));
+      final CleanCommand command = CleanCommand();
+      command.deleteFile(mockFile);
       expect(logger.errorText, contains('A program may still be using a file'));
+      verify(mockFile.deleteSync(recursive: true)).called(1);
     }, overrides: <Type, Generator>{
-      Config: () => null,
-      FileSystem: () => mockFileSystem,
       Platform: () => windowsPlatform,
       Logger: () => BufferLogger(),
+      Xcode: () => mockXcode,
     });
   });
 }
 
-class MockFileSystem extends Mock implements FileSystem {}
 class MockFile extends Mock implements File {}
-class MockDirectory extends Mock implements Directory {}
 class MockPlatform extends Mock implements Platform {}
+class MockXcode extends Mock implements Xcode {}
+
+class MockXcodeProjectInterpreter extends Mock implements XcodeProjectInterpreter {
+  @override
+  Future<XcodeProjectInfo> getInfo(String projectPath) async {
+    return XcodeProjectInfo(null, null, <String>['Runner']);
+  }
+}
diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
index 99b96bb..17e6f8d 100644
--- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
+++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import 'package:flutter_tools/src/base/io.dart' show ProcessException, ProcessResult;
+import 'package:flutter_tools/src/base/platform.dart';
 import 'package:flutter_tools/src/ios/xcodeproj.dart';
 import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:mockito/mockito.dart';
@@ -13,17 +14,20 @@
 
 class MockProcessManager extends Mock implements ProcessManager {}
 class MockXcodeProjectInterpreter extends Mock implements XcodeProjectInterpreter {}
+class MockPlatform extends Mock implements Platform {}
 
 void main() {
   group('Xcode', () {
     MockProcessManager mockProcessManager;
     Xcode xcode;
     MockXcodeProjectInterpreter mockXcodeProjectInterpreter;
+    MockPlatform mockPlatform;
 
     setUp(() {
       mockProcessManager = MockProcessManager();
       mockXcodeProjectInterpreter = MockXcodeProjectInterpreter();
       xcode = Xcode();
+      mockPlatform = MockPlatform();
     });
 
     testUsingContext('xcodeSelectPath returns null when xcode-select is not installed', () {
@@ -89,6 +93,80 @@
       XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
     });
 
+    testUsingContext('isInstalledAndMeetsVersionCheck is false when not macOS', () {
+      when(mockPlatform.isMacOS).thenReturn(false);
+      expect(xcode.isInstalledAndMeetsVersionCheck, isFalse);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+      Platform: () => mockPlatform,
+    });
+
+    testUsingContext('isInstalledAndMeetsVersionCheck is false when not installed', () {
+      when(mockPlatform.isMacOS).thenReturn(true);
+
+      const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
+        .thenReturn(ProcessResult(1, 0, xcodePath, ''));
+
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(false);
+      expect(xcode.isInstalledAndMeetsVersionCheck, isFalse);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+      Platform: () => mockPlatform,
+      ProcessManager: () => mockProcessManager
+    });
+
+    testUsingContext('isInstalledAndMeetsVersionCheck is false when no xcode-select', () {
+      when(mockPlatform.isMacOS).thenReturn(true);
+
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
+        .thenReturn(ProcessResult(1, 127, '', 'ERROR'));
+
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(9);
+      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(1);
+
+      expect(xcode.isInstalledAndMeetsVersionCheck, isFalse);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+      Platform: () => mockPlatform,
+      ProcessManager: () => mockProcessManager
+    });
+
+    testUsingContext('isInstalledAndMeetsVersionCheck is false when version not satisfied', () {
+      when(mockPlatform.isMacOS).thenReturn(true);
+
+      const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
+        .thenReturn(ProcessResult(1, 0, xcodePath, ''));
+
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(8);
+      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+      expect(xcode.isInstalledAndMeetsVersionCheck, isFalse);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+      Platform: () => mockPlatform,
+      ProcessManager: () => mockProcessManager
+    });
+
+    testUsingContext('isInstalledAndMeetsVersionCheck is true when macOS and installed and version is satisfied', () {
+      when(mockPlatform.isMacOS).thenReturn(true);
+
+      const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
+        .thenReturn(ProcessResult(1, 0, xcodePath, ''));
+
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(9);
+      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(1);
+      expect(xcode.isInstalledAndMeetsVersionCheck, isTrue);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+      Platform: () => mockPlatform,
+      ProcessManager: () => mockProcessManager
+    });
+
     testUsingContext('eulaSigned is false when clang is not installed', () {
       when(mockProcessManager.runSync(<String>['/usr/bin/xcrun', 'clang']))
           .thenThrow(const ProcessException('/usr/bin/xcrun', <String>['clang']));
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart
index d5a15a8..090680e 100644
--- a/packages/flutter_tools/test/src/context.dart
+++ b/packages/flutter_tools/test/src/context.dart
@@ -336,6 +336,10 @@
   }
 
   @override
+  void cleanWorkspace(String workspacePath, String scheme) {
+  }
+
+  @override
   Future<XcodeProjectInfo> getInfo(String projectPath) async {
     return XcodeProjectInfo(
       <String>['Runner'],