Summary
This fixes a warm-build regression in tuist test --build-only that appeared after 4.196.1 for projects whose Swift targets reference generated dependency module maps.
The fix keeps generated compiler inputs stable across warm generation, while still deleting stale generated files:
- Preserve generated file mtimes when the bytes on disk already match the generated contents.
- Keep
Derived/ModuleMaps and Derived/FrameworkSearchPaths stable during the early /Derived cleanup, so active compiler inputs are not deleted before graph mappers regenerate them.
- Model stale generated-file cleanup as a declarative
GeneratedFilesCleanupDescriptor, then execute the async filesystem glob/remove work in SideEffectDescriptorExecutor.
- Scope cleanup with include patterns, so we remove Tuist-owned generated files such as
*-deps.modulemap and *.resp without deleting unrelated files in the same directories.
Root Cause
4.196.1 introduced generated dependency module maps for targets that need to pass dependency module maps through to Swift/Clang compilation. In the affected CI build, the newer Tuist generated many Derived/ModuleMaps/*-deps.modulemap files, and many Swift compile commands referenced them via -fmodule-map-file=....
The generated module map contents were stable, but their filesystem state was not. Two generator behaviors combined badly with the new files:
DeleteDerivedDirectoryProjectMapper cleaned the project Derived directory before graph mapping. It preserved top-level *.modulemap files, but not the generated Derived/ModuleMaps directory. That removed the dependency module maps between invocations.
SideEffectDescriptorExecutor handled .file(..., contents:) by writing the file unconditionally. Even if the generated bytes were identical to the existing file, the write updated the file modification time.
Because generated dependency module maps are compiler inputs, Xcode treated the changed mtimes as changed build inputs. That invalidated Swift compilation in otherwise warm tuist test --build-only runs.
The first version of this fix preserved the generated directories in the early cleanup and let the mappers emit file-delete side effects for stale files. That solved the rebuild regression, but it also mixed two models in the graph-mapping layer: the mappers were doing filesystem observation, then returning declarative side effects. The final version keeps mappers declarative. They only describe which generated files are active and which generated filename patterns Tuist owns; SideEffectDescriptorExecutor performs the async filesystem reads, globs, and removals.
Solution
SideEffectDescriptorExecutor now checks existing file contents before writing generated files. If the file exists and its bytes already match the generated bytes, the executor returns without rewriting it. That keeps the file mtime stable for unchanged generated files.
DeleteDerivedDirectoryProjectMapper preserves these generated dependency directories during the early project cleanup:
Derived/ModuleMaps
Derived/FrameworkSearchPaths
It still removes transient generated directories such as Derived/InfoPlists.
Stale cleanup is represented by GeneratedFilesCleanupDescriptor:
ModuleMapMapper records active generated dependency module maps and emits cleanup for *-deps.modulemap.
FrameworkSearchPathsGraphMapper records active generated response files and emits cleanup for *.resp.
SideEffectDescriptorExecutor owns the async FileSystem work: it checks whether each cleanup directory exists, globs the descriptor’s include patterns, and removes only matching generated files that are not in the active set.
This preserves both properties we need: active compiler inputs keep stable mtimes across warm generation, and stale generated module maps / response files are still removed once they are no longer active.
The include patterns are intentional. External SwiftPM module maps can coexist near Tuist’s generated dependency module maps, so broad sibling deletion would be risky. The executor cleanup test now verifies that Package.modulemap is preserved while stale *-deps.modulemap files are removed.
User Impact
Warm tuist test --build-only invocations should stay incremental again for projects affected by generated dependency module maps. In practice, this avoids unnecessary Swift recompilation after project generation when the module map contents have not changed.
This mostly affects stateful CI or local warm-build workflows. On a truly ephemeral runner with no reused DerivedData or generated project state, there is no previous warm compiler state to invalidate, so the regression is much less visible. Oura’s reports line up with stateful CI behavior: generated compiler inputs were being recreated or rewritten between invocations, which made Xcode rebuild Swift files that should have remained incremental.
Validation
I first reproduced the regression locally with the mise-installed released Tuist versions using a small macOS fixture under /private/tmp/tuist-modulemap-repro.
The fixture has:
- a C static library target with a custom module map,
- a Swift framework target importing that C module across 20 Swift files,
- a unit test target depending on the Swift framework.
I ran each version twice with a dedicated DerivedData directory. The first run warmed DerivedData; the second run measured whether tuist test --build-only stayed incremental.
Released Versions
tuist@4.195.0:
- Warm second run:
real 2.01
- Xcode output showed no Swift recompilation.
- This behaved as the expected baseline.
tuist@4.196.1:
- Warm second run:
real 3.45
- Xcode recompiled all 20 Swift framework files plus the test file.
- Session logs showed generated
Derived/ModuleMaps/*-deps.modulemap files.
- The generated module map mtimes were updated during the second run, even though the logical contents were unchanged.
That reproduced the build-time regression locally with the requested mise-installed versions.
Patched Tuist
I built Tuist from this branch and ran the same fixture with a fresh DerivedData path:
- First patched run compiled the fixture as expected.
- Warm second patched run before the stale-cleanup refinement:
real 1.67
- After moving stale cleanup into the side-effect executor, the warm patched run stayed no-op:
real 1.29
- Xcode output only processed Info.plists.
- No Swift files were recompiled.
- Existing active
Derived/ModuleMaps/*-deps.modulemap files were not rewritten.
I also validated stale-file behavior directly in the fixture:
- Created
/private/tmp/tuist-modulemap-repro/Derived/ModuleMaps/StaleTarget-deps.modulemap
- Ran the patched
tuist test AppCore --build-only ...
- Result:
real 1.38, no Swift recompilation, and the fake stale module map was deleted.
That validates the fix end-to-end: the generated project still builds, active generated module maps remain available with stable mtimes, stale generated files are cleaned, and the warm test --build-only path no longer invalidates Swift compilation.
Focused Tests
After merging origin/main, I regenerated the focused test workspace:
mise exec -- tuist generate TuistGeneratorTests --no-open
Then I ran the focused Xcode tests:
xcodebuild test \
-workspace Tuist.xcworkspace \
-scheme TuistUnitTests \
-destination platform=macOS \
-only-testing TuistGeneratorTests/ModuleMapMapperTests \
-only-testing TuistGeneratorTests/FrameworkSearchPathsGraphMapperTests \
-only-testing TuistGeneratorTests/SideEffectDescriptorExecutorTests \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
Result: passed.
The run covered:
ModuleMapMapperTests.test_map_deletesStaleGeneratedDependencyModuleMaps
ModuleMapMapperTests.test_maps_long_dependency_chain_without_recursion
FrameworkSearchPathsGraphMapperTests.test_map_deletesStaleFrameworkSearchPathResponseFiles
SideEffectDescriptorExecutorTests/execute_cleansStaleGeneratedFiles()
- the existing mapper and side-effect executor tests selected by those suites
I also ran:
mise exec -- swift build --replace-scm-with-registry --target TuistCore
mise exec -- swift build --replace-scm-with-registry --target TuistGenerator
git diff --check
Results: both targets built successfully, and whitespace/conflict-marker hygiene checks were clean.