Repository navigation
fix: settle object identifiers when encoding project.xcproj - #1180
Merged
pepicrft merged 1 commit intoSep 18, 2026
Merged
Conversation
This was referenced Sep 18, 2026
freddi-kit
approved these changes
Sep 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
There is no issue for this one. I hit it while adding
project.xcprojsupport to XcodeGen.Short description 📝
Encoding the same project twice as
project.xcprojgives two different files. Every object identifier in the output is a freshTEMP_<UUID>.A
PBXObjectstarts out with a temporary reference, andReferenceGeneratorturns those into deterministic ones. It runs when reading aproject.xcproj(PBXProj+XCProj.swift:26) and when encoding aproject.pbxproj(PBXProjEncoder.swift:48). Nothing runs it when encoding aproject.xcproj, soxcprojDataserializes whatever the objects happen to be holding.That hits any graph that was assembled rather than read from a file: project generators, and any object added during a read, modify and write. The same project written as
project.pbxprojis byte identical across runs. Written asproject.xcprojit is not:For a generator whose output is committed, that is a diff on every regeneration. It also breaks a byte compare before writing, so an unchanged project gets rewritten every time.
Solution 📦
Settle the references at the top of
XCProjEncoder.encode(), the same placePBXProjEncoder.encode(proj:)does it:PBXOutputSettings()is what the decoder passes atPBXProj+XCProj.swift:26, so both directions agree on the format, and it is the 24 character form Xcode writes itself. A project read from disk is unaffected, becausegenerateReferencescaches every non-temporary reference before it starts andfixReferenceskips them.PBXProj.init(xcprojData:projectName:)keeps its own call, since a caller can read a project and work with it without ever encoding it again.I looked at two other options.
Putting the call in
XCProjEncoder.init. It works, and no call site changes, since they all readtry XCProjEncoder(...).encode()already. I kept it inencode()because that matchesPBXProjEncoder, and because building an encoder should not rewrite the graph you handed it. It would also be the only throwing initializer in the library.Threading a reference format through
XCProjOutputSettings.fixReferenceonly touches a reference that is still temporary, so the first encoder to run fixes the format for good. The library already behaves that way: encode a project asproject.pbxprojwith default settings, then again withPBXOutputSettings(projReferenceFormat: .withPrefixAndSuffix), and the second setting is ignored onmaintoday. This change puts the JSON encoder under the same rule, so a graph encoded asproject.xcprojand then written asproject.pbxprojwith a custom format now gets.xcode. You need a non-default format and onePBXProjwritten both ways to hit it, and the freezing is what makes the identifiers stable in the first place. Threading the setting through would cover both paths including the decode side. Say the word and I will do it here instead.One thing about the existing tests:
XCProjEncoderTests.makeProjcallsgenerateReferencesitself before it returns, so every in-memory test reached the encoder with its references already settled. That is why none of them caught this. The new test builds its graph without that call.Implementation 👩💻👨💻
XCProjEncoder.encode().settlesTheIdentifiersOfAProjectAssembledInMemorytoXCProjEncoderTests.Testing
Both directions are covered.
TEMP_. This is what a project generator hands over. It fails onmainon both counts, which I confirmed by stashing only the source change and running the test against it, so it is not passing for some unrelated reason..preserveAllround trip covers that and still passes, becausegenerateReferencescaches every non-temporary reference before it starts, so nothing that came out of a file is rewritten.383 XCTest and 84 Swift Testing tests pass on macOS.
mise run lintpasses. Theline_length,type_body_length(287/250) andfunction_body_length(54/50) warnings on the two touched files are already onmain; the new test raises the two counts without adding a violation. I did not run the Linux or iOS jobs locally.One note for
tuist/tuist. tuist/tuist#13359 opted its ownTuist.swiftinto.xcproj, and its writer keeps a serialize, compare and only rewrite on diff fast path whose comment says generation is byte deterministic. That holds forproject.pbxproj, since those stable references come fromReferenceGeneratorin the plist encoder, but it would not have held on the.xcprojpath, so the comparison never matched and the dogfoodedTuist.xcodeproj/project.xcprojchurns identifiers on every generation. I could not check that directly because**/*.xcodeprojis gitignored there. This change fixes it with nothing needed on the Tuist side. That PR also saysxcprojData(settings:)isinternalin 9.17.0 and drops the byte compare because of it, but it sits insidepublic extension PBXProj, so it is public in both 9.17.0 and 9.17.1 and that fast path can come back.