From d5d77334c4cab8dabf420e43d979ed15f5cd4876 Mon Sep 17 00:00:00 2001 From: Kyle Satti Date: Wed, 18 Feb 2026 19:03:42 -0500 Subject: [PATCH 1/2] Fall back to Xcode's default DerivedData when using --skip-build When --skip-build is used without --index-store-path, periphery looks for the index store in its own DerivedData cache directory. However, when the build was performed by Xcode or xcodebuild directly, the index store lives in Xcode's default DerivedData location instead. This adds a fallback that searches ~/Library/Developer/Xcode/DerivedData for a matching project directory when the index store is not found in periphery's own cache. The most recently modified matching directory is preferred. Fixes #1082 --- Sources/XcodeSupport/Xcodebuild.swift | 57 ++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/Sources/XcodeSupport/Xcodebuild.swift b/Sources/XcodeSupport/Xcodebuild.swift index 576982164..cbfbb1fb1 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,47 @@ public final class Xcodebuild { } } + private func findIndexStorePath(in derivedDataPath: FilePath) -> FilePath? { + ["Index.noindex/DataStore", "Index/DataStore"] + .map { derivedDataPath.appending($0) } + .first { $0.exists } + } + + private func findIndexStoreInDefaultDerivedData(projectName: String) -> FilePath? { + let defaultDerivedData = FilePath( + NSHomeDirectory() + ).appending("Library/Developer/Xcode/DerivedData") + + guard defaultDerivedData.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: defaultDerivedData.string) else { + return nil + } + + let candidates = entries + .filter { $0.hasPrefix("\(projectName)-") } + .compactMap { entry -> (path: FilePath, modified: Date)? in + let entryPath = defaultDerivedData.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 From 349cb37ac03d42e55e557e3ee46693f614838eef Mon Sep 17 00:00:00 2001 From: Kyle Satti Date: Wed, 18 Feb 2026 19:13:25 -0500 Subject: [PATCH 2/2] Add tests for index store path fallback logic Make findIndexStorePath and findIndexStoreInDerivedData internal for testability. Extract the core DerivedData search into a findIndexStoreInDerivedData(projectName:derivedDataRoot:) overload that accepts a custom root path, enabling unit tests with temp directories. Add 11 tests covering both findIndexStorePath and the DerivedData fallback: modern vs legacy index layouts, preference ordering, project name matching edge cases, and most-recently-modified selection. --- Sources/XcodeSupport/Xcodebuild.swift | 14 +- .../XcodeTests/XcodebuildIndexStoreTest.swift | 144 ++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 Tests/XcodeTests/XcodebuildIndexStoreTest.swift diff --git a/Sources/XcodeSupport/Xcodebuild.swift b/Sources/XcodeSupport/Xcodebuild.swift index cbfbb1fb1..d2461ed31 100644 --- a/Sources/XcodeSupport/Xcodebuild.swift +++ b/Sources/XcodeSupport/Xcodebuild.swift @@ -125,30 +125,34 @@ public final class Xcodebuild { } } - private func findIndexStorePath(in derivedDataPath: FilePath) -> FilePath? { + func findIndexStorePath(in derivedDataPath: FilePath) -> FilePath? { ["Index.noindex/DataStore", "Index/DataStore"] .map { derivedDataPath.appending($0) } .first { $0.exists } } - private func findIndexStoreInDefaultDerivedData(projectName: String) -> FilePath? { + func findIndexStoreInDefaultDerivedData(projectName: String) -> FilePath? { let defaultDerivedData = FilePath( NSHomeDirectory() ).appending("Library/Developer/Xcode/DerivedData") - guard defaultDerivedData.exists else { return nil } + 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: defaultDerivedData.string) else { + 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 = defaultDerivedData.appending(entry) + let entryPath = derivedDataRoot.appending(entry) guard let attrs = try? fm.attributesOfItem(atPath: entryPath.string), let modified = attrs[.modificationDate] as? Date else { return nil 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) + } +}