No linked issue — originated from an internal proposal to make a no-op tuist generate cheaper for downstream Xcode builds.
What changed
XcodeProjWriter previously called project.xcodeProj.write(path:), which always deletes and rewrites project.pbxproj on every tuist generate — even when the freshly serialized bytes are identical to what’s already on disk.
This replaces that call with writeSkippingUnchangedPBXProj, which mirrors XcodeProj.write‘s ordering (workspace → pbxproj → shared data → user data) and adds a content guard around the pbxproj write: serialize once via the already-public dataRepresentation(outputSettings:), compare against the on-disk file, and write only on a real difference. The serialized bytes are reused for the write, so there’s no double serialization — the only added cost over today is a single read + compare.
Why
Tuist’s generation is byte-deterministic for an unchanged input: PBX references are stable hashes of identity (ReferenceGenerator.fixReference) and serialization order is sorted, so a no-op regeneration produces identical bytes. Leaving the file untouched in that case:
- preserves its modification time/inode, eliminating needless churn that shows up as spurious
git diffs and file-watcher wakeups, and
- brings the pbxproj writer in line with what
SideEffectDescriptorExecutor.process(file:) already does for generated files (Info.plists, generated sources) — it skips the write when contents match. The pbxproj was simply one of the last writers not sharing that pattern.
On a real structural change the bytes differ and the file is written exactly as before, so there is no behavior change on real regenerations.
On build times — measured, honest result
The original motivation was a downstream Xcode build win (the theory: a rewritten pbxproj bumps mtime → Xcode regenerates its build description → broad recompile). I benchmarked this and it did not hold on the xcodebuild path (Xcode 26.5).
Faithful A/B on a generated 7-target macOS project (app + 6 frameworks, 120 sources), warm cache, varying only whether the no-op generate’s pbxproj rewrite is undone (exact inode+mtime+content restored via hard link):
| Condition |
pbxproj after no-op generate |
recompiles |
relinks |
incremental build time |
| Today (rewritten) |
inode+mtime changed |
0 |
0 |
~1.15 s |
| Skip-write (untouched) |
unchanged |
0 |
0 |
~0.94 s |
| Upper bound (nothing touched at all) |
unchanged |
0 |
0 |
~0.85 s |
The pbxproj rewrite does make xcodebuild run CreateBuildDescription, but that does not cascade into any recompilation — llbuild’s compile tasks are content-keyed, so the regenerated build description is byte-identical and every task is skipped. Times across conditions overlap within noise.
Conclusion: this is taken on hygiene grounds, not as a build-time optimization. For xcodebuild/CI it produces no measurable speedup. The one scenario not covered here is the Xcode IDE (a persistent process that holds the build description in memory and watches the filesystem), which is the only place the predicted effect could still be hiding — worth a follow-up measurement before claiming any build-cache benefit.
Scope / follow-ups
- Guards the pbxproj only. The workspace and scheme writers still rewrite unconditionally; the scheme directory is also wiped before rewriting (
XcodeProjWriter.swift:111-113). Extending the guard to those is a straightforward follow-up but wasn’t needed to establish the behavior.
- Considered landing this upstream in
tuist/XcodeProj (PBXProj.write + the other Writable conformers), which would cover workspace/schemes for free. Kept it at the Tuist layer instead to keep the fork minimal and the behavior Tuist-controlled, matching where SideEffectDescriptorExecutor already does the same thing.
How to test locally
tuist generate a project, then build it (warm the incremental cache).
- Run
tuist generate again without changing anything.
- Confirm
project.pbxproj’s mtime is unchanged (stat -f %m **/project.pbxproj) and git status is clean for it.
- Make a real structural change (add a target/file), regenerate, and confirm the pbxproj is rewritten as expected.