Skip to content

fix: settle object identifiers when encoding project.xcproj - #1180

Merged
pepicrft merged 1 commit into
tuist:mainfrom
antigluten:fix/xcproj-deterministic-object-ids
Sep 18, 2026
Merged

pepicrft merged 1 commit into
tuist:mainfrom
antigluten:fix/xcproj-deterministic-object-ids

Conversation

@antigluten

Copy link
Copy Markdown
Contributor

There is no issue for this one. I hit it while adding project.xcproj support to XcodeGen.

Short description 📝

Encoding the same project twice as project.xcproj gives two different files. Every object identifier in the output is a fresh TEMP_<UUID>.

A PBXObject starts out with a temporary reference, and ReferenceGenerator turns those into deterministic ones. It runs when reading a project.xcproj (PBXProj+XCProj.swift:26) and when encoding a project.pbxproj (PBXProjEncoder.swift:48). Nothing runs it when encoding a project.xcproj, so xcprojData serializes 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.pbxproj is byte identical across runs. Written as project.xcproj it is not:

let first = try makeProject().xcprojData()
let second = try makeProject().xcprojData()
// first != second
// { "path": "<PRODUCTS>/App.app", "id": "TEMP_BAAE7BB9-3394-440A-A7BA-F4A5C6DC510A", ... }

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 place PBXProjEncoder.encode(proj:) does it:

try ReferenceGenerator(outputSettings: PBXOutputSettings()).generateReferences(proj: proj)

PBXOutputSettings() is what the decoder passes at PBXProj+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, because generateReferences caches every non-temporary reference before it starts and fixReference skips 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 read try XCProjEncoder(...).encode() already. I kept it in encode() because that matches PBXProjEncoder, 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. fixReference only 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 as project.pbxproj with default settings, then again with PBXOutputSettings(projReferenceFormat: .withPrefixAndSuffix), and the second setting is ignored on main today. This change puts the JSON encoder under the same rule, so a graph encoded as project.xcproj and then written as project.pbxproj with a custom format now gets .xcode. You need a non-default format and one PBXProj written 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.makeProj calls generateReferences itself 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 👩‍💻👨‍💻

  • Generate the references at the top of XCProjEncoder.encode().
  • Add settlesTheIdentifiersOfAProjectAssembledInMemory to XCProjEncoderTests.

Testing

Both directions are covered.

  • A graph assembled in memory is encoded twice and compared byte for byte, and the output is checked for any surviving TEMP_. This is what a project generator hands over. It fails on main on 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.
  • A project read from disk has to keep the identifiers it arrived with. The existing .preserveAll round trip covers that and still passes, because generateReferences caches every non-temporary reference before it starts, so nothing that came out of a file is rewritten.
  • The output is still checked as a format, not only as bytes. The existing tests feed everything the encoder produces back through Apple's decoder, so a passing run is also a format check.

383 XCTest and 84 Swift Testing tests pass on macOS. mise run lint passes. The line_length, type_body_length (287/250) and function_body_length (54/50) warnings on the two touched files are already on main; 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 own Tuist.swift into .xcproj, and its writer keeps a serialize, compare and only rewrite on diff fast path whose comment says generation is byte deterministic. That holds for project.pbxproj, since those stable references come from ReferenceGenerator in the plist encoder, but it would not have held on the .xcproj path, so the comparison never matched and the dogfooded Tuist.xcodeproj/project.xcproj churns identifiers on every generation. I could not check that directly because **/*.xcodeproj is gitignored there. This change fixes it with nothing needed on the Tuist side. That PR also says xcprojData(settings:) is internal in 9.17.0 and drops the byte compare because of it, but it sits inside public extension PBXProj, so it is public in both 9.17.0 and 9.17.1 and that fast path can come back.

@pepicrft
pepicrft merged commit 91f1f22 into tuist:main Sep 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants