diff --git a/Sources/XcodeProj/Utils/ReferenceGenerator.swift b/Sources/XcodeProj/Utils/ReferenceGenerator.swift index 221352355..5d2617af4 100644 --- a/Sources/XcodeProj/Utils/ReferenceGenerator.swift +++ b/Sources/XcodeProj/Utils/ReferenceGenerator.swift @@ -18,6 +18,11 @@ final class ReferenceGenerator: ReferenceGenerating { var references: Set = [] /// The type name and reference acronym of each object type, which only depend on the type. private var typeNamesAndAcronyms: [ObjectIdentifier: (typeName: String, acronym: String)] = [:] + /// Build phase membership exception sets waiting for their build phases' references, with their identifiers. + private var buildPhaseMembershipExceptionSets: [( + exceptionSet: PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet, + identifiers: [String] + )] = [] init(outputSettings: PBXOutputSettings) { self.outputSettings = outputSettings @@ -65,6 +70,17 @@ final class ReferenceGenerator: ReferenceGenerating { let targets: [PBXTarget] = project.targets try targets.forEach { try generateTargetReferences($0, identifiers: identifiers) } + // Build phase membership exception sets, whose references derive from their build phases' references, which + // the targets have just generated. + for (exceptionSet, identifiers) in buildPhaseMembershipExceptionSets { + var identifiers = identifiers + if let buildPhase = exceptionSet.buildPhase { + identifiers.append(buildPhase.reference.value) + } + fixReference(for: exceptionSet, identifiers: identifiers) + } + buildPhaseMembershipExceptionSets = [] + /// Configuration list if let configurationList: XCConfigurationList = project.buildConfigurationListReference.getObject() { try generateConfigurationListReferences(configurationList, identifiers: identifiers) @@ -202,10 +218,8 @@ final class ReferenceGenerator: ReferenceGenerating { } fixReference(for: buildFileException, identifiers: identifiers) } else if let buildPhaseException = exceptionSet as? PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet { - if let buildPhase = buildPhaseException.buildPhase { - identifiers.append(buildPhase.reference.value) - } - fixReference(for: buildPhaseException, identifiers: identifiers) + // Its build phase may not have a permanent reference yet, so it's generated after the targets. + buildPhaseMembershipExceptionSets.append((buildPhaseException, identifiers)) } } diff --git a/Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift b/Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift index 1d365c82f..6e6f57397 100644 --- a/Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift +++ b/Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift @@ -33,7 +33,8 @@ struct ReferenceFixingTests { } #if os(macOS) || (os(Linux) && compiler(>=6.1)) - /// Writing a project whose references are all temporary moves every object it decodes to a permanent reference. + /// Writing a project whose references are all temporary moves every object it decodes to a permanent reference, + /// and gives each the same reference every time. @Test(arguments: [ "iOS/Project.xcodeproj", "iOS/AppWithExtensions/AppWithExtensions.xcodeproj", @@ -43,15 +44,19 @@ struct ReferenceFixingTests { "Xcode16ProjectReferenceOrder/Test.xcodeproj", ]) func writingAProjectWithTemporaryReferences_keepsEveryObject(fixture: String) throws { - let pbxproj = try PBXProj(path: fixturesPath() + fixture + "project.pbxproj") - let objects = objectCountsByType(pbxproj.objects) - pbxproj.objects.invalidateReferences() - - let data = try #require(try pbxproj.dataRepresentation()) + func write() throws -> (objects: [String: Int], data: Data) { + let pbxproj = try PBXProj(path: fixturesPath() + fixture + "project.pbxproj") + let objects = objectCountsByType(pbxproj.objects) + pbxproj.objects.invalidateReferences() + return try (objects, #require(try pbxproj.dataRepresentation())) + } + + let (objects, data) = try write() let written = try PBXProj(data: data) #expect(objectCountsByType(written.objects) == objects) #expect(!String(decoding: data, as: UTF8.self).contains("TEMP_")) + #expect(try write().data == data) } #endif diff --git a/Tests/XcodeProjTests/Utils/ReferenceGeneratorTests.swift b/Tests/XcodeProjTests/Utils/ReferenceGeneratorTests.swift index a09c79cb1..c1621addb 100644 --- a/Tests/XcodeProjTests/Utils/ReferenceGeneratorTests.swift +++ b/Tests/XcodeProjTests/Utils/ReferenceGeneratorTests.swift @@ -155,6 +155,41 @@ class ReferenceGeneratorTests: XCTestCase { XCTAssert(!buildPhaseException.reference.temporary, "Build phase exception reference should not be temporary") XCTAssertFalse(buildPhaseException.reference.value.hasPrefix("TEMP_"), "Build phase exception reference should not have TEMP_ prefix") } + + func test_projectWithFilesystemSynchronizedRootGroupWithBuildPhaseException_generatesDeterministicIdentifiers() throws { + // The build phase starts with a temporary reference, as it does in a project created in code. + func generateProject() throws -> [String] { + let project = PBXProj(rootObject: nil, objectVersion: 0, archiveVersion: 0, classes: [:], objects: []) + let pbxProject = project.makeProject() + + let target = project.makeTarget() + pbxProject.targets.append(target) + + let buildPhase = PBXSourcesBuildPhase() + project.add(object: buildPhase) + target.buildPhases.append(buildPhase) + + let buildPhaseException = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet( + buildPhase: buildPhase, + membershipExceptions: ["ExcludedFile.swift"], + attributesByRelativePath: nil + ) + project.add(object: buildPhaseException) + + let syncedGroup = project.makeSynchronizedRootGroup(exceptions: [buildPhaseException]) + target.fileSystemSynchronizedGroups = [syncedGroup] + + let referenceGenerator = ReferenceGenerator(outputSettings: PBXOutputSettings()) + try referenceGenerator.generateReferences(proj: project) + + return [buildPhase, buildPhaseException, syncedGroup].map(\.reference.value) + } + + let firstReferences = try generateProject() + let secondReferences = try generateProject() + + XCTAssertEqual(firstReferences, secondReferences) + } } private extension PBXProj {