Tuist Module Cache for Modular and Agentic Workflows
Tuist module cache is great. But treating it as one magic switch is where the pain starts.
In a large modular iOS project, you do not have one workflow. You have several:
- sometimes you want to run the app and review what a coding agent changed in Xcode,
- sometimes you use static linking but need SwiftUI Previews for one module,
- sometimes you are the coding agent and you do not need Xcode at all, just a fast CLI feedback loop.
Those are different jobs. One generated workspace will not be good at all of them.
The setup I like is boring:
-
all-possible- cache everything Tuist can safely replace with binaries, -
only-external- cache third-party dependencies only, -
a few
misetasks so I do not have to remember long commands.
No custom build system. No clever wrapper around a wrapper. Just enough command surface for the actual workflows.
Tuist Setup
The project-specific details do not matter much. The important part is that generation can switch three things from the command line:
-
cache profile: aggressive
allPossiblevsonlyExternal, -
configuration: usually
Debug, - linking: static by default, dynamic for SwiftUI Previews.
For previews I pass TUIST_LINKING=dynamic into Tuist manifests and switch module products from static frameworks to dynamic frameworks. That’s it. It should be a switch, not a subsystem.
1. Reviewing Agent Work
The problem: a coding agent changed a few files. I want to run the app, click through the result, and see exactly those changed modules in Xcode.
I do not want the full workspace. I also do not want the changed module hidden behind cache.
Command:
mise run tuist:generate:diff
Task:
[tasks."tuist:generate:diff"]
description = "Generate the fast Xcode workspace with changed modules from source and the rest cached."
depends = ["tuist:install"]
run = '''
set -e
: "${TUIST_GENERATE_CACHE_PROFILE:=all-possible}"
: "${TUIST_GENERATE_CONFIGURATION:=Debug}"
TUIST_GENERATE_TARGETS="$(scripts/tuist-generate-diff-targets.py)"
tuist generate $TUIST_GENERATE_TARGETS --no-open --cache-profile "$TUIST_GENERATE_CACHE_PROFILE" --configuration "$TUIST_GENERATE_CONFIGURATION"
'''
The helper script does the boring work:
-
reads staged, unstaged, and untracked files from
git diff, -
maps paths like
Modules/Features/Home/...to Tuist targets, - always includes the main app target so the app can run,
- lets Tuist expand the dependency graph.
I could publish this script, but I do not think that is the useful part anymore. In the age of AI coding, it is usually better to ask your agent to generate this small mapper for your project structure: your module paths, your target naming rules, your test target conventions, your edge cases.
So when Home changes, I do not try to hand-write the entire target list. I pass the app and the changed modules. Tuist adds whatever the graph requires: dependencies, test targets, and dependent modules that need to be built from source. Everything else can come from cache.
The local dependent modules are useful during review too. Sometimes the changed code calls into another app module and I want to jump straight to that file in Xcode. Third-party dependencies can stay binary; local code is often worth keeping reachable.
That’s the whole trick.
2. SwiftUI Previews with Static Linking
Static linking is good for normal builds. SwiftUI Previews are less happy with it. For preview work, I generate a dynamic workspace for the module I am touching.
Not the whole app. Just the module.
Command:
mise run tuist:generate:preview Home
Task:
[tasks."tuist:generate:preview"]
description = "Generate a dynamic preview workspace for explicit module targets."
depends = ["tuist:install"]
run = '''
set -e
if [ "$#" -eq 0 ] && [ -z "${TUIST_GENERATE_TARGETS:-}" ]; then
echo "usage: mise run tuist:generate:preview <target> [target...]" >&2
exit 2
fi
targets=("$@")
if [ "$#" -eq 0 ]; then
read -r -a targets <<< "$TUIST_GENERATE_TARGETS"
fi
: "${TUIST_GENERATE_CACHE_PROFILE:=all-possible}"
: "${TUIST_GENERATE_CONFIGURATION:=Debug}"
TUIST_LINKING=dynamic tuist generate "${targets[@]}" --no-open --cache-profile "$TUIST_GENERATE_CACHE_PROFILE" --configuration "$TUIST_GENERATE_CONFIGURATION"
'''
Why allPossible? Because the target passed to tuist generate is focused and stays source-based. The rest can be cached.
3. Coding Agent Feedback Loop
A coding agent does not need Xcode. It does not need source files visible in the project navigator. It needs to generate, build, test, and get feedback quickly.
For that, the aggressive app workspace is the default:
mise run tuist:generate:app
Task:
[tasks."tuist:generate:app"]
description = "Generate the fast Xcode workspace using the full Tuist module cache."
depends = ["tuist:install"]
run = '''
set -e
: "${TUIST_GENERATE_CACHE_PROFILE:=all-possible}"
: "${TUIST_GENERATE_CONFIGURATION:=Debug}"
tuist generate App --no-open --cache-profile "$TUIST_GENERATE_CACHE_PROFILE" --configuration "$TUIST_GENERATE_CONFIGURATION"
'''
This is the “give me the fastest useful feedback” mode. If the agent is building and testing through CLI, keeping every local module source-based buys nothing.
Benchmarks
Environment: Tuist 4.200.5, Xcode 26.5.
Each variant ran 3 times with a fresh DerivedData. I did not benchmark tuist generate alone because that number is mostly a distraction. The useful number is generate + clean build.
| Variant | What it builds | Average | Range | Workspace |
|---|---|---|---|---|
app |
app target, everything else cached as much as possible | 13.790 s | 13.641-13.938 s | 1 project |
diff |
app + modules from the diff + graph required by Tuist | 22.410 s | 20.763-25.092 s | 4 projects |
all-modules |
all local modules from source | 65.293 s | 61.996-69.962 s | 37 projects |
The difference is not subtle. generate:diff was 42.883 s faster than generating all local modules from source. That is the point: for review, I want the changed code visible and buildable, not every module in the repository.
generate:app is even faster, but it solves a different problem. It is great for normal CLI feedback. It is worse for reviewing agent work because Xcode will not show the modules the agent changed.
For coding agents, I still default to allPossible. They do not use Xcode, so keeping local modules source-based is just ceremony.
The Whole Setup
The command set is small:
# fast app workspace
mise run tuist:generate:app
# review current git diff
mise run tuist:generate:diff
# SwiftUI Preview for one module
mise run tuist:generate:preview Home
# baseline/debug: all local modules from source, external deps cached
mise run tuist:generate:all-modules
# warm the default cache
mise run tuist:cache:warm
That covers the workflows I actually use day to day.
The important part is not the exact task names. It is having separate commands for separate jobs: app feedback, agent review, preview work, and the full source workspace when you really need it.