What changed
- Add
CacheHashingGraphMapper to TuistKit’s graph-mapper pipeline. It captures the normalized pre-focus graph in MapperEnvironment.initialGraphWithSources without mutating the graph that continues through generation.
- Preserve that graph before focused generation or cache warming applies focus/tree-shaking, but after hash-impacting normalization has run.
- Keep the TuistCacheEE change focused on consumption:
TargetsToCacheBinariesGraphMapper hashes the preserved source graph when available, while fetching and mutating only targets that remain in the current focused graph.
- Update factory tests and mapper tests to cover the ownership boundary: TuistKit captures the graph; TuistCacheEE scopes cache lookup to the focused graph.
The issue
Binary cache artifacts are keyed by each target’s content hash. That hash is not only source-file content: it also includes hash-affecting target settings and dependency information. This means two runs can have identical source sub-hashes but still ask cache storage for different artifacts if the dependency graph seen by the hasher is different.
tuist hash cache and cache warming compute hashes from the source graph before focused generation removes targets. Focused tuist generate, however, can run focus/tree-shaking before the binary-cache mapper. That focused graph is the right graph to generate, but it is not always the right graph to hash for cache lookup. Once focus has pruned targets or changed the dependency closure, the dependency sub-hash can drift from the hash that warm/check produced. The cache lookup then correctly misses because generate is asking for an artifact key that was never warmed.
A first attempt stored the graph before focus, but command-level validation showed that this was still too early. Some graph mappers that run after the initial graph construction also affect the eventual hash inputs, especially target settings and dependency metadata. Preserving the raw pre-focus graph could therefore still diverge from cache warming. The preserved graph has to be both pre-focus and normalized through the same hash-impacting phase.
Why this approach
The fix separates the graph used for cache-key computation from the graph used for project generation:
- TuistKit owns capturing the graph because it owns graph-mapper ordering and the normalization phase.
- TuistCacheEE owns consuming the captured graph because it owns binary-cache hashing, artifact lookup, and graph mutation.
- The hashing graph is captured before focus/tree-shaking so cache keys do not depend on which subset of targets the user asked to generate.
- The hashing graph is normalized first, so it matches the graph shape used by
tuist hash cache and cache warming.
- The live graph continues through focus/tree-shaking unchanged, so focused generation still generates only the requested graph shape.
- Cache artifact fetching is scoped back to the targets available in the focused graph, so we do not fetch or report cache items for targets that are not part of the current generation.
This is intentionally narrower than changing the content hasher or storage behavior. Removing dependency information from the hash would make cache keys less representative of the artifact that was actually built. Moving the binary-cache replacement entirely before focus would make focused generate do unnecessary cache work for targets that will later be removed. Reconstructing the unfocused dependency graph inside the hasher would duplicate graph-mapper semantics in the hashing layer. Capturing a normalized pre-focus graph in TuistKit keeps the cache key stable while preserving the existing generation flow.
Impact
Focused generate should now ask storage for the same artifact hashes that cache warm/check produced, reducing fixed-target misses caused by graph-mapper ordering rather than real source or configuration changes.
Validation
- Compared downloaded session data and confirmed the investigated pair showed hash divergence rather than an identical-hash storage miss.
- Tried to reproduce a natural old-red/new-green with four local fixture variants: conditional destinations, resources, local package/macro chain, and a multi-project transitive graph. The release CLI did not naturally reproduce the exact old-red in those small fixtures.
- Caught the incomplete first fix with command-level validation:
hash cache and focused generate still differed when the graph was preserved too early.
- Final patched CLI command-level validation on a local multi-project fixture:
tuist hash cache --path <fixture>
tuist generate <focused-target> --path <fixture> --no-open --cache-profile all-possible
- Both sessions emitted identical target hashes for the cacheable framework targets.
- After moving the capture mapper into TuistKit:
- Regenerated the source-only workspace with
tuist generate tuist TuistCacheEETests TuistKitTests ProjectDescription --no-open --no-binary-cache.
xcodebuild build-for-testing -workspace Tuist.xcworkspace -scheme Tuist-Workspace ... passed with Xcode 26.5.
- Direct test bundle execution passed:
TuistKitTests/CacheHashingGraphMapperTests: 2 mapper tests.
TuistKitTests/CacheGraphMapperFactoryTests: 3 factory-ordering tests.
TuistCacheEETests/TargetsToCacheBinariesGraphMapperTests/test_map_hashes_preserved_sources_graph_and_scopes_hashes_to_current_graph.
- After converting
CacheHashingGraphMapperTests to Swift Testing:
xcrun swiftc -parse cli/Tests/TuistKitTests/Mappers/Graph/CacheHashingGraphMapperTests.swift passed.
git diff --check passed.
- A repeat local Xcode build could not be run because Xcode build actions on this host are currently blocked by the Xcode license prompt.
Submodule PR: https://github.com/tuist/TuistCacheEE/pull/63