Hive Hive
Sign in

feat(cli): skip rewriting project.pbxproj when generation output is unchanged

GitHub issue · Closed

Metadata
Source
tuist/tuist #11500
Updated
Jul 5, 2026
Domains
Generated projects
Details

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

  1. tuist generate a project, then build it (warm the incremental cache).
  2. Run tuist generate again without changing anything.
  3. Confirm project.pbxproj’s mtime is unchanged (stat -f %m **/project.pbxproj) and git status is clean for it.
  4. Make a real structural change (add a target/file), regenerate, and confirm the pbxproj is rewritten as expected.
Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments