Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ public struct Shell: Sendable {
}

public var xcodeSelectPrintPath = Shell.shared.xcodeSelectPrintPath
/// Returns the path printed by `xcode-select -p`, or `nil` if no developer directory is selected.
///
/// `xcode-select -p` fails when no developer directory is selected,
/// e.g. on a Mac that doesn't have Xcode or the Command Line Tools installed yet.
public func selectedXcodePath() async throws -> String? {
do {
return try await xcodeSelectPrintPath().out
} catch is ProcessExecutionError {
return nil
}
}

public var xcodeSelectSwitch = Shell.shared.xcodeSelectSwitch
public var isRoot: @Sendable () -> Bool = { NSUserName() == "root" }
Expand Down
8 changes: 4 additions & 4 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ public final class XcodeInstaller: Sendable {
}

public func printAvailableXcodes(_ xcodes: [Xcode], installed installedXcodes: [InstalledXcode], dataSource: DataSource = .xcodeReleases, architectures: [ArchitectureFilter] = []) async throws {
let output = try await Current.shell.xcodeSelectPrintPath()
let selectedXcodePath = try await Current.shell.selectedXcodePath()
let machineArchitecture = Current.shell.machineArchitecture()
let effectiveArchitectures = architectures.isEmpty
? [ArchitectureFilter].defaultForMachine(machineHardwareName: machineArchitecture)
Expand All @@ -508,7 +508,7 @@ public final class XcodeInstaller: Sendable {
.availableRows(
availableXcodes: xcodes,
installedXcodes: installedXcodes,
selectedXcodePath: output.out,
selectedXcodePath: selectedXcodePath,
dataSource: dataSource,
architectures: effectiveArchitectures
)
Expand All @@ -530,12 +530,12 @@ public final class XcodeInstaller: Sendable {
}

public func printInstalledXcodes(directory: Path) async throws {
let pathOutput = try await Current.shell.xcodeSelectPrintPath()
let selectedXcodePath = try await Current.shell.selectedXcodePath()
let selectedString = "(Selected)"
let presentationService = XcodeListPresentationService()
let rows = presentationService.installedRows(
installedXcodes: Current.files.installedXcodes(directory),
selectedXcodePath: pathOutput.out
selectedXcodePath: selectedXcodePath
)

for line in presentationService.installedLines(rows: rows, interactive: Current.shell.isatty(), selectedMarker: selectedString) {
Expand Down
15 changes: 8 additions & 7 deletions Sources/XcodesKit/XcodeSelect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import Rainbow
import XcodesKit

public func selectXcodeAsync(shouldPrint: Bool, pathOrVersion: String, directory: Path, fallbackToInteractive: Bool = true) async throws {
let output = try await Current.shell.xcodeSelectPrintPath()
let selectedXcodePath = try await Current.shell.selectedXcodePath()

if shouldPrint {
if output.out.isEmpty == false {
Current.logging.log(output.out)
if let selectedXcodePath, selectedXcodePath.isEmpty == false {
Current.logging.log(selectedXcodePath)
} else {
Current.logging.log("No selected Xcode")
}
Expand All @@ -26,7 +26,7 @@ public func selectXcodeAsync(shouldPrint: Bool, pathOrVersion: String, directory
switch selectionService.request(
pathOrVersion: pathOrVersion,
installedXcodes: installedXcodes,
selectedXcodePath: output.out
selectedXcodePath: selectedXcodePath ?? ""
) {
case let .alreadySelectedVersion(version):
Current.logging.log("Xcode \(version) is already selected".green)
Expand All @@ -37,18 +37,19 @@ public func selectXcodeAsync(shouldPrint: Bool, pathOrVersion: String, directory
Current.logging.log("Selected \(selectedOutput.out)".green)
Current.shell.exit(0)
return
case .alreadySelectedPath:
case .alreadySelectedPath where selectedXcodePath != nil:
Current.logging.log("Xcode at path \(pathOrVersion) is already selected".green)
Current.shell.exit(0)
return
case let .selectPath(pathToSelect):
// With no Xcode selected, an empty path comes back as `.alreadySelectedPath`, so handle it like `.selectPath`
case let .alreadySelectedPath(pathToSelect), let .selectPath(pathToSelect):
do {
let selectedOutput = try await selectXcodeAtPathAsync(pathToSelect)
Current.logging.log("Selected \(selectedOutput.out)".green)
Current.shell.exit(0)
} catch {
guard fallbackToInteractive else { throw error }
let selectedOutput = try await selectXcodeInteractivelyAsync(currentPath: output.out, directory: directory)
let selectedOutput = try await selectXcodeInteractivelyAsync(currentPath: selectedXcodePath ?? "", directory: directory)
Current.logging.log("Selected \(selectedOutput.out)".green)
Current.shell.exit(0)
}
Expand Down
129 changes: 129 additions & 0 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ private func configureRainbowForTest(outputTarget: OutputTarget, enabled: Bool)
final class XcodesKitTests: XCTestCase {
static let mockXcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)

/// `xcode-select -p` exits with status 2 when no developer directory is selected,
/// e.g. on a Mac that doesn't have Xcode or the Command Line Tools installed yet.
static var noSelectedXcodeError: ProcessExecutionError {
ProcessExecutionError(process: Process(), terminationStatus: 2, standardOutput: nil, standardError: nil)
}

var xcodeList: XcodeList!
var xcodeInstaller: XcodeInstaller!
var sessionService: AppleSessionService!
Expand Down Expand Up @@ -1033,6 +1039,21 @@ final class XcodesKitTests: XCTestCase {
XCTAssertFalse(log.value.contains("Options:"))
}

func test_PrintAvailableXcodes_WithNoSelectedXcode_PrintsXcodes() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }
Current.shell.xcodeSelectPrintPath = { throw Self.noSelectedXcodeError }

try await xcodeInstaller.printAvailableXcodes([Self.mockXcode], installed: [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!])

XCTAssertEqual(log.value, """
0.0 (ABC123) (Installed)

Showing Xcodes for this Mac by default: Apple Silicon (arm64). Switch with `--architecture arm64`, `--architecture x86_64`, `--architecture appleSilicon`, or `--architecture universal`.

""")
}

func test_SelectPrint() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }
Expand All @@ -1052,6 +1073,24 @@ final class XcodesKitTests: XCTestCase {
""")
}

func test_SelectPrint_WithNoSelectedXcode() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }

Current.files.installedXcodes = { _ in
[InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!]
}

Current.shell.xcodeSelectPrintPath = { throw Self.noSelectedXcodeError }

try await selectXcodeAsync(shouldPrint: true, pathOrVersion: "", directory: Path.root.join("Applications"))

XCTAssertEqual(log.value, """
No selected Xcode

""")
}

func test_SelectPath() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }
Expand Down Expand Up @@ -1199,6 +1238,70 @@ final class XcodesKitTests: XCTestCase {
""")
}

func test_SelectInteractively_WithNoSelectedXcode() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }

// There are installed Xcodes
Current.files.installedXcodes = { _ in
[InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!,
InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!)!]
}
Current.files.contentsAtPath = { path in
if path == "/Applications/Xcode-0.0.0.app/Contents/Info.plist" {
let url = Bundle.module.url(forResource: "Stub-0.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")!
return try? Data(contentsOf: url)
}
else if path == "/Applications/Xcode-2.0.1.app/Contents/Info.plist" {
let url = Bundle.module.url(forResource: "Stub-2.0.1.Info", withExtension: "plist", subdirectory: "Fixtures")!
return try? Data(contentsOf: url)
}
else if path.contains("version.plist") {
let url = Bundle.module.url(forResource: "Stub.version", withExtension: "plist", subdirectory: "Fixtures")!
return try? Data(contentsOf: url)
}
else {
return nil
}
}
Current.files.fileExistsAtPath = { path in
if path == "" {
return false
}
return true
}
// No Xcode is selected until one is switched to
let xcodeSelectPrintPathCallCount = LockedBox(0)
Current.shell.xcodeSelectPrintPath = {
if xcodeSelectPrintPathCallCount.increment() == 1 {
throw Self.noSelectedXcodeError
}
else {
return (status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "")
}
}
// User enters an index
XcodesCLIKit.Current.shell.readLine = { prompt in
XcodesCLIKit.Current.logging.log(prompt)
return "2"
}
// It successfully switches
Current.shell.xcodeSelectSwitch = { _, _ in
(status: 0, out: "", err: "")
}

try await selectXcodeAsync(shouldPrint: false, pathOrVersion: "", directory: Path.root.join("Applications"))

XCTAssertEqual(log.value, """
Available Xcode versions:
1) 0.0 (ABC123)
2) 2.0.1 (ABC123)
Enter the number of the Xcode to select:
Selected /Applications/Xcode-2.0.1.app/Contents/Developer

""")
}

func test_SelectUsingXcodeVersionFile() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }
Expand Down Expand Up @@ -1367,6 +1470,32 @@ final class XcodesKitTests: XCTestCase {
)
}

func test_Installed_WithNoSelectedXcode() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }

// There is an installed Xcode
Current.files.installedXcodes = { _ in
[InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!]
}

// None is selected
Current.shell.xcodeSelectPrintPath = { throw Self.noSelectedXcodeError }

// Standard output is not an interactive terminal
Current.shell.isatty = { false }

try await xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications")

XCTAssertEqual(
log.value,
"""
0.0 (ABC123)\t/Applications/Xcode-0.0.0.app

"""
)
}

func test_Installed_WithValidVersion_PrintsXcodePath() async throws {
let log = LockedBox("")
XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }
Expand Down