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
23 changes: 22 additions & 1 deletion Sources/XcodesKit/Models+FirstWithVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,28 @@ public extension Array where Element == InstalledXcode {
/// If there are multiple matches, or no matches, nil is returned.
func first(withVersion version: Version) -> InstalledXcode? {
findXcode(version: version, in: self, versionKeyPath: \.version)
}
}

/// Returns the newest InstalledXcode that shares the requested major and minor versions when:
/// - No prerelease or build metadata identifiers were specified.
/// - The requested patch version is 0 (i.e. not explicitly provided).
/// - An exact match via `first(withVersion:)` could not be found.
/// Prefers release builds when available, otherwise falls back to prerelease builds.
func latestMatchingMajorMinorVersion(withVersion version: Version) -> InstalledXcode? {
let matchingVersions = filter {
$0.version.major == version.major &&
$0.version.minor == version.minor
}

if matchingVersions.isEmpty {
return nil
}

let releaseVersions = matchingVersions.filter { $0.version.prereleaseIdentifiers.isEmpty }
let candidates = releaseVersions.isEmpty ? matchingVersions : releaseVersions

return candidates.max(by: { $0.version < $1.version })
}
}

extension Version {
Expand Down
19 changes: 15 additions & 4 deletions Sources/XcodesKit/XcodeSelect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,27 @@ public func selectXcode(shouldPrint: Bool, pathOrVersion: String, directory: Pat

let versionToSelect = pathOrVersion.isEmpty ? Version.fromXcodeVersionFile() : Version(xcodeVersion: pathOrVersion)
let installedXcodes = Current.files.installedXcodes(directory)
if let version = versionToSelect,
let installedXcode = installedXcodes.first(withVersion: version) {
if let version = versionToSelect {
let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version }
if installedXcode.version == selectedInstalledXcodeVersion {
if version == selectedInstalledXcodeVersion {
Current.logging.log("Xcode \(version) is already selected".green)
Current.shell.exit(0)
return Promise.value(())
}

return selectXcodeAtPath(installedXcode.path.string)
var xcodeToSelect = installedXcodes.first(withVersion: version)
if xcodeToSelect == nil, let fallbackInstalledXcode = installedXcodes.latestMatchingMajorMinorVersion(withVersion: version) {
Current.logging.log("Xcode \(version) is not installed. Selecting \(fallbackInstalledXcode.version) instead.".green)
xcodeToSelect = fallbackInstalledXcode
}

guard let selectedXcode = xcodeToSelect else {
Current.logging.log("Xcode \(version) is not installed".red)
Current.shell.exit(1)
return Promise.value(())
}

return selectXcodeAtPath(selectedXcode.path.string)
.done { output in
Current.logging.log("Selected \(output.out)".green)
Current.shell.exit(0)
Expand Down
32 changes: 32 additions & 0 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,38 @@ final class XcodesKitTests: XCTestCase {

""")
}

func test_SelectFallsBackToLatestPatchVersion() {
var log = ""
XcodesKit.Current.logging.log = { log.append($0 + "\n") }

let fallbackXcode = InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!, version: Version(2, 0, 1))
Current.files.installedXcodes = { _ in [fallbackXcode] }

var xcodeSelectPrintPathCallCount = 0
Current.shell.xcodeSelectPrintPath = {
defer { xcodeSelectPrintPathCallCount += 1 }
if xcodeSelectPrintPathCallCount == 0 {
return Promise.value((status: 0, out: "/Applications/Xcode-1.0.0.app/Contents/Developer", err: ""))
} else {
return Promise.value((status: 0, out: "\(fallbackXcode.path.string)/Contents/Developer", err: ""))
}
}

Current.shell.xcodeSelectSwitch = { _, path in
XCTAssertEqual(path, fallbackXcode.path.string)
return Promise.value((status: 0, out: "", err: ""))
}

selectXcode(shouldPrint: false, pathOrVersion: "2.0", directory: Path.root.join("Applications"))
.cauterize()

XCTAssertEqual(log, """
Xcode 2.0 is not installed. Selecting 2.0.1 instead.
Selected /Applications/Xcode-2.0.1.app/Contents/Developer

""")
}

func test_Installed_InteractiveTerminal() {
var log = ""
Expand Down