diff --git a/Sources/XcodeSupport/Xcodebuild.swift b/Sources/XcodeSupport/Xcodebuild.swift index 576982164..d2461ed31 100644 --- a/Sources/XcodeSupport/Xcodebuild.swift +++ b/Sources/XcodeSupport/Xcodebuild.swift @@ -61,13 +61,19 @@ public final class Xcodebuild { public func indexStorePath(project: XcodeProjectlike, schemes: [String]) throws -> FilePath { let derivedDataPath = try derivedDataPath(for: project, schemes: schemes) - let pathsToTry = ["Index.noindex/DataStore", "Index/DataStore"] - .map { derivedDataPath.appending($0) } - guard let path = pathsToTry.first(where: { $0.exists }) else { - throw PeripheryError.indexStoreNotFound(derivedDataPath: derivedDataPath.string) + + if let path = findIndexStorePath(in: derivedDataPath) { + return path } - return path + // Periphery's own DerivedData may not exist when --skip-build is used and + // the project was built by Xcode or xcodebuild. Fall back to searching + // Xcode's default DerivedData location for a matching project directory. + if let path = findIndexStoreInDefaultDerivedData(projectName: project.name) { + return path + } + + throw PeripheryError.indexStoreNotFound(derivedDataPath: derivedDataPath.string) } func schemes(project: XcodeProjectlike, additionalArguments: [String]) throws -> Set { @@ -119,6 +125,51 @@ public final class Xcodebuild { } } + func findIndexStorePath(in derivedDataPath: FilePath) -> FilePath? { + ["Index.noindex/DataStore", "Index/DataStore"] + .map { derivedDataPath.appending($0) } + .first { $0.exists } + } + + func findIndexStoreInDefaultDerivedData(projectName: String) -> FilePath? { + let defaultDerivedData = FilePath( + NSHomeDirectory() + ).appending("Library/Developer/Xcode/DerivedData") + + return findIndexStoreInDerivedData(projectName: projectName, derivedDataRoot: defaultDerivedData) + } + + func findIndexStoreInDerivedData(projectName: String, derivedDataRoot: FilePath) -> FilePath? { + guard derivedDataRoot.exists else { return nil } + + // Xcode names DerivedData subdirectories as "-". + // Find the most recently modified one matching the project name. + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: derivedDataRoot.string) else { + return nil + } + + let candidates = entries + .filter { $0.hasPrefix("\(projectName)-") } + .compactMap { entry -> (path: FilePath, modified: Date)? in + let entryPath = derivedDataRoot.appending(entry) + guard let attrs = try? fm.attributesOfItem(atPath: entryPath.string), + let modified = attrs[.modificationDate] as? Date else { + return nil + } + return (entryPath, modified) + } + .sorted { $0.modified > $1.modified } + + for candidate in candidates { + if let path = findIndexStorePath(in: candidate.path) { + return path + } + } + + return nil + } + private func derivedDataPath(for project: XcodeProjectlike, schemes: [String]) throws -> FilePath { // Given a project with two schemes: A and B, a scenario can arise where the index store contains conflicting // data. If scheme A is built, then the source file modified and then scheme B built, the index store will diff --git a/Tests/XcodeTests/XcodebuildIndexStoreTest.swift b/Tests/XcodeTests/XcodebuildIndexStoreTest.swift new file mode 100644 index 000000000..1ac08013b --- /dev/null +++ b/Tests/XcodeTests/XcodebuildIndexStoreTest.swift @@ -0,0 +1,144 @@ +import Foundation +import Logger +import SystemPackage +@testable import XcodeSupport +import XCTest + +final class XcodebuildIndexStoreTest: XCTestCase { + private var xcodebuild: Xcodebuild! + private var tmpDir: FilePath! + + override func setUp() { + super.setUp() + + let logger = Logger(quiet: true, verbose: false, colorMode: .never) + let shell = ShellMock(output: "") + xcodebuild = Xcodebuild(shell: shell, logger: logger) + tmpDir = FilePath(NSTemporaryDirectory()).appending("XcodebuildIndexStoreTest-\(UUID().uuidString)") + try! FileManager.default.createDirectory(atPath: tmpDir.string, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(atPath: tmpDir.string) + xcodebuild = nil + tmpDir = nil + super.tearDown() + } + + // MARK: - findIndexStorePath(in:) + + func testFindIndexStorePathFindsIndexNoindex() throws { + let dataStore = tmpDir.appending("Index.noindex/DataStore") + try FileManager.default.createDirectory(atPath: dataStore.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStorePath(in: tmpDir) + XCTAssertEqual(result, dataStore) + } + + func testFindIndexStorePathFindsLegacyIndex() throws { + let dataStore = tmpDir.appending("Index/DataStore") + try FileManager.default.createDirectory(atPath: dataStore.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStorePath(in: tmpDir) + XCTAssertEqual(result, dataStore) + } + + func testFindIndexStorePathPrefersIndexNoindex() throws { + let noindex = tmpDir.appending("Index.noindex/DataStore") + let legacy = tmpDir.appending("Index/DataStore") + try FileManager.default.createDirectory(atPath: noindex.string, withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: legacy.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStorePath(in: tmpDir) + XCTAssertEqual(result, noindex) + } + + func testFindIndexStorePathReturnsNilWhenNoneExist() { + let result = xcodebuild.findIndexStorePath(in: tmpDir) + XCTAssertNil(result) + } + + // MARK: - findIndexStoreInDerivedData(projectName:derivedDataRoot:) + + func testFindIndexStoreInDerivedDataFindsMatchingProject() throws { + let projectDir = tmpDir.appending("MyProject-abc123") + let dataStore = projectDir.appending("Index.noindex/DataStore") + try FileManager.default.createDirectory(atPath: dataStore.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStoreInDerivedData(projectName: "MyProject", derivedDataRoot: tmpDir) + XCTAssertEqual(result, dataStore) + } + + func testFindIndexStoreInDerivedDataReturnsNilForNonMatchingProject() throws { + let projectDir = tmpDir.appending("OtherProject-abc123") + let dataStore = projectDir.appending("Index.noindex/DataStore") + try FileManager.default.createDirectory(atPath: dataStore.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStoreInDerivedData(projectName: "MyProject", derivedDataRoot: tmpDir) + XCTAssertNil(result) + } + + func testFindIndexStoreInDerivedDataReturnsNilWhenRootDoesNotExist() { + let nonexistent = tmpDir.appending("nonexistent") + + let result = xcodebuild.findIndexStoreInDerivedData(projectName: "MyProject", derivedDataRoot: nonexistent) + XCTAssertNil(result) + } + + func testFindIndexStoreInDerivedDataReturnsNilWhenNoIndexStore() throws { + let projectDir = tmpDir.appending("MyProject-abc123") + try FileManager.default.createDirectory(atPath: projectDir.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStoreInDerivedData(projectName: "MyProject", derivedDataRoot: tmpDir) + XCTAssertNil(result) + } + + func testFindIndexStoreInDerivedDataPrefersMostRecentlyModified() throws { + let fm = FileManager.default + + // Create an older project directory with a valid index store + let olderDir = tmpDir.appending("MyProject-older111") + let olderDataStore = olderDir.appending("Index.noindex/DataStore") + try fm.createDirectory(atPath: olderDataStore.string, withIntermediateDirectories: true) + + // Set its modification date to the past + try fm.setAttributes( + [.modificationDate: Date.distantPast], + ofItemAtPath: olderDir.string + ) + + // Create a newer project directory with a valid index store + let newerDir = tmpDir.appending("MyProject-newer222") + let newerDataStore = newerDir.appending("Index.noindex/DataStore") + try fm.createDirectory(atPath: newerDataStore.string, withIntermediateDirectories: true) + + // Set its modification date to now + try fm.setAttributes( + [.modificationDate: Date()], + ofItemAtPath: newerDir.string + ) + + let result = xcodebuild.findIndexStoreInDerivedData(projectName: "MyProject", derivedDataRoot: tmpDir) + XCTAssertEqual(result, newerDataStore) + } + + func testFindIndexStoreInDerivedDataDoesNotMatchExactName() throws { + // A directory named exactly "MyProject" (no hash suffix) should not match + let projectDir = tmpDir.appending("MyProject") + let dataStore = projectDir.appending("Index.noindex/DataStore") + try FileManager.default.createDirectory(atPath: dataStore.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStoreInDerivedData(projectName: "MyProject", derivedDataRoot: tmpDir) + XCTAssertNil(result) + } + + func testFindIndexStoreInDerivedDataDoesNotMatchPrefix() throws { + // "MyProjectExtra-abc123" should not match project name "MyProject" + let projectDir = tmpDir.appending("MyProjectExtra-abc123") + let dataStore = projectDir.appending("Index.noindex/DataStore") + try FileManager.default.createDirectory(atPath: dataStore.string, withIntermediateDirectories: true) + + let result = xcodebuild.findIndexStoreInDerivedData(projectName: "MyProject", derivedDataRoot: tmpDir) + XCTAssertNil(result) + } +}