From 5f74120a19bd81d348dcfbc633aaa6f987ff53ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Wed, 4 Nov 2026 16:44:55 +0100 Subject: [PATCH] fix: generate deterministic references for build phase membership exception sets A PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet's reference is derived from its build phase's reference, but exception sets were fixed while walking the groups, before the targets pass makes build phase references permanent. A build phase with a temporary reference therefore fed a random TEMP_ UUID into the hash, so the same project got different references on every write. Exception sets of that kind are now fixed after the targets. When the build phase already has a permanent reference the identifiers are the same as before, so existing output doesn't change. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a0d7-4cfc-76a2-8b2d-344510b78c8c Co-authored-by: Amp --- .../XcodeProj/Utils/ReferenceGenerator.swift | 22 +++++++++--- .../Utils/ReferenceFixingTests.swift | 17 +++++---- .../Utils/ReferenceGeneratorTests.swift | 35 +++++++++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) 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 {