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'],