Git Worktrees for iOS: A Practical Setup Guide
Why Worktrees?
If you’ve ever been mid-way through a feature branch, hit a blocker, and needed to switch to a hotfix — only to wait for Xcode to re-index everything — you already know the pain. git stash, switch branches, wait for the index to rebuild, fix the bug, switch back, pop the stash, wait for the index again. It’s slow and it breaks your flow.
Git worktrees solve this. A worktree is a separate checkout of your repo that shares the same .git directory. Each worktree can be on a different branch, with its own working tree, its own build artifacts, and its own Xcode state. You can have your feature branch open in one Xcode window and your hotfix in another, simultaneously, without conflict.
# Create a worktree for a hotfix while keeping your feature branch open
git worktree add ../MyApp-hotfix -b hotfix/crash-on-launch
That’s it. You now have a second working copy at ../MyApp-hotfix. Open it in Xcode, fix the bug, commit, push, and remove the worktree when you’re done:
git worktree remove ../MyApp-hotfix
The concept is simple. The iOS-specific gotchas are where it gets interesting.
DerivedData: The Silent Win
Xcode stores build artifacts in ~/Library/Developer/Xcode/DerivedData/. Each project gets a folder named {ProjectName}-{hash}, where the hash is derived from the absolute path to the workspace or project file. This is the single most important detail for worktree-based iOS development.
Because each worktree lives at a different filesystem path, Xcode automatically creates a separate DerivedData folder for each one. You get full build isolation for free. No shared indexing, no shared caches, no “phantom build errors because the other branch’s artifacts leaked in.” Each worktree is a clean, independent build environment.
The trade-off: your first build in a new worktree is a cold build. For a small project, that’s maybe 30 seconds. For a large project with hundreds of thousands of lines, it could be 10–15 minutes. That’s real time, and it’s worth knowing about up front. Once the initial build completes, incremental builds behave exactly as they do in a normal checkout.
Three Strategies for DerivedData
1. Full Isolation (Default) — Do nothing. Let Xcode handle it. Each worktree gets its own DerivedData folder automatically. This is the simplest and safest approach. Recommended for most projects.
2. Shared DerivedData — Point all worktrees at the same DerivedData directory to share build caches:
xcodebuild \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-derivedDataPath "$HOME/SharedDerivedData/MyApp" \
build
This speeds up initial builds since compiled modules and frameworks are reused. But concurrent builds from different worktrees can step on each other — Xcode wasn’t designed for two processes writing to the same DerivedData simultaneously. If you go this route, serialize your builds.
3. Worktree-local DerivedData — Store build artifacts inside the worktree itself:
xcodebuild \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-derivedDataPath ./DerivedData \
build
Add DerivedData/ to your .gitignore. This keeps everything self-contained and makes cleanup trivial — remove the worktree and the build artifacts go with it. The downside is that Xcode’s GUI won’t automatically use this path unless you also configure it in Xcode’s preferences (Xcode → Settings → Locations → Derived Data → Relative), and that’s a global setting that affects all projects.
For most teams, Strategy 1 is the right default. The cold build cost is real but one-time, and the isolation guarantees are worth it.
Swift Package Manager
SPM is mostly worktree-friendly out of the box, with one nuance worth understanding.
SPM maintains a global download cache at ~/Library/Caches/org.swift.swiftpm/. When you resolve packages in one worktree, the downloaded archives are cached globally. A second worktree resolving the same dependencies will skip the download and pull from cache. This means the network cost of package resolution is paid once, regardless of how many worktrees you have.
However, each worktree still gets its own SourcePackages/ directory (inside DerivedData or alongside your project, depending on configuration). The source checkout and compilation of packages happens per-worktree. For projects with heavy dependencies, you can pre-warm the cache:
# In your main checkout, resolve everything once
swift package resolve
# New worktrees will reuse the downloaded archives
git worktree add ../MyApp-feature -b feature/new-thing
One gotcha: if different worktrees are on branches with different Package.resolved files (different dependency versions), SPM will resolve to whatever that worktree’s lockfile says. This is correct behavior, but it means you might see different versions of the same package across worktrees. That’s usually fine — it’s the point of isolation — but it can be surprising if you’re not expecting it.
The project.pbxproj Problem
Xcode’s project.pbxproj is notoriously merge-unfriendly, and worktrees make conflicts more likely since you’re more often working on parallel branches that touch the project file simultaneously. This is a problem you’ve likely already dealt with, and the solutions are the same ones you’d use without worktrees — custom merge drivers like mergepbx, or project generation tools like Tuist and XcodeGen that let you .gitignore the project file entirely and regenerate per-worktree. If you haven’t adopted one of these yet, a worktree-heavy workflow is a good reason to start.
Simulator Conflicts
Each Xcode instance shares the same simulator runtime. If two worktrees try to run tests on the same simulator simultaneously, you’ll hit Simulator in use errors or flaky test failures.
The fix is to clone simulators — create a dedicated simulator per worktree:
# Create a clone for your feature worktree
xcrun simctl clone "iPhone 16 Pro" "iPhone 16 Pro - Feature"
# Run tests against the clone
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro - Feature'
When you’re done with the worktree, clean up the simulator:
xcrun simctl delete "iPhone 16 Pro - Feature"
For CI environments, this is less of a concern since each job typically runs in its own VM. But for local development with multiple simultaneous worktrees, simulator cloning is the safest approach.
Automating It
Manually cloning and deleting simulators gets old fast. A pair of shell functions can tie simulator lifecycle to worktree lifecycle:
# Add to your .zshrc or .bashrc
worktree-add() {
local path="$1"; shift
git worktree add "$path" "$@"
local name=$(basename "$path")
xcrun simctl clone "iPhone 16 Pro" "Sim - $name" 2>/dev/null && \
echo "Created simulator 'Sim - $name'"
}
worktree-remove() {
local path="$1"
local name=$(basename "$path")
xcrun simctl delete "Sim - $name" 2>/dev/null && \
echo "Deleted simulator 'Sim - $name'"
git worktree remove "$path"
}
Now worktree-add ../MyApp-feature -b feature/auth creates the worktree and a dedicated simulator in one shot, and worktree-remove ../MyApp-feature tears both down. The simulator name is derived from the worktree directory name, so there’s no bookkeeping to manage.
You can take this further — wrap xcodebuild test to auto-discover the right simulator based on the current working directory, or add a worktree-prune that cleans up orphaned simulators by cross-referencing git worktree list with xcrun simctl list devices. But the two functions above cover 90% of the workflow.
Agentic Workflows
If you’re using AI coding agents (Claude Code, Codex, etc.) in worktrees, the agents don’t know about your simulator setup unless you tell them. The trick is to make the convention discoverable through files the agent already reads.
In your AGENTS.md or CLAUDE.md:
## Testing
Run tests using the Makefile: `make test`
Do NOT run xcodebuild test directly — the Makefile handles
simulator selection based on the current worktree.
Then in your Makefile, derive the simulator name from the working directory:
WORKTREE_NAME := $(notdir $(CURDIR))
SIM_NAME := Sim - $(WORKTREE_NAME)
# Fall back to a default simulator if no worktree-specific one exists
SIM_EXISTS := $(shell xcrun simctl list devices | grep -q '$(SIM_NAME)' && echo 1)
DESTINATION := $(if $(SIM_EXISTS),$(SIM_NAME),iPhone 16 Pro)
test:
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=$(DESTINATION)'
The agent runs make test, the Makefile figures out the right simulator, and nobody has to think about it. The same pattern works for build commands, linting, or anything else that needs worktree-aware configuration. The key insight is that agents follow instructions in markdown files and invoke commands from Makefiles — so put the worktree intelligence in those layers, not in the agent’s prompt.
Xcode’s Index and SourceKit
Xcode’s indexer (and by extension, autocomplete, jump-to-definition, and syntax highlighting) operates per-workspace. Since each worktree has its own .xcworkspace at a different path, each one gets an independent index. This is good — it means changes in one worktree won’t corrupt the index of another.
The downside is resource usage. Each open Xcode workspace runs its own SourceKit-LSP process and index store. On a MacBook with 16GB of RAM, having three worktrees open simultaneously is fine. Beyond that, you’ll start to feel it. Close worktrees you’re not actively working in, or at least close the Xcode windows to free up the indexer.
If you ever see stale index results (wrong autocomplete, phantom errors), the nuclear option is:
# Delete the index for a specific worktree's DerivedData
rm -rf ~/Library/Developer/Xcode/DerivedData/MyApp-<hash>/Index.noindex
Xcode will rebuild the index on next open. It takes a minute or two, but it resolves most indexing ghosts.
Worktree Lifecycle Tips
A few practical tips for managing worktrees in an iOS context:
Keep worktrees short-lived. Create one for a feature or bugfix, merge it, remove it. Long-lived worktrees accumulate stale state and eat disk space via DerivedData.
Use a consistent directory layout. I keep all worktrees as siblings of the main checkout:
~/Developer/
MyApp/ # main checkout
MyApp-feature-auth/ # worktree
MyApp-hotfix-crash/ # worktree
This keeps paths short (important for Xcode, which can struggle with deeply nested paths) and makes it obvious which directory is which.
Clean up regularly. Git tracks worktrees, so you can see what’s active:
git worktree list
Prune stale entries (worktrees whose directories were deleted without git worktree remove):
git worktree prune
Mind your DerivedData disk usage. Each worktree’s DerivedData can be several gigabytes for a large project. If you’re creating and destroying worktrees frequently, the orphaned DerivedData folders stick around. Periodically clean them up:
# List DerivedData folders sorted by size
du -sh ~/Library/Developer/Xcode/DerivedData/* | sort -rh
Or just nuke the ones you don’t recognize — Xcode will rebuild as needed.
Putting It Together
Here’s my typical workflow when I need to work on something in parallel:
# 1. Create the worktree
git worktree add ../MyApp-feature -b feature/new-thing
# 2. Open it in Xcode
open ../MyApp-feature/MyApp.xcworkspace
# 3. Wait for the initial build and index (grab coffee)
# 4. Work on the feature as normal
# 5. When done, commit and push from the worktree
cd ../MyApp-feature
git add -A && git commit -m "feat: add new thing"
git push -u origin feature/new-thing
# 6. Clean up
cd ../MyApp
git worktree remove ../MyApp-feature
The friction is almost entirely in step 3 — the cold build. Everything else is standard git. Once you internalize the pattern, switching between parallel workstreams becomes second nature.
Automating Worktree Management with Claudio
If you find yourself reaching for worktrees often — especially when running AI coding agents in parallel — managing the lifecycle manually gets tedious. That’s one of the reasons I built Claudio.
Claudio is a CLI/TUI tool that orchestrates multiple Claude Code (or Codex) instances, each in its own worktree. It handles the worktree creation, branch management, and cleanup automatically. But the iOS-specific wins go deeper than convenience:
- DerivedData isolation is automatic. Each Claudio instance gets its own worktree at a unique path, so Xcode’s path-based hashing gives you fully isolated build artifacts without any configuration.
- Simulator contention is avoided by design. Since each instance operates in its own worktree, you can configure separate simulator destinations per instance to avoid
Simulator in useerrors. .pbxprojconflict detection is built in. Claudio monitors which files each instance touches and raises alerts when multiple instances modify the same file — critical for catching project file conflicts before merge time.- Cleanup is one command.
claudio cleanupremoves stale worktrees and their branches in one shot. No more orphaned DerivedData folders from worktrees you forgot to remove.
If you’re already bought into the worktree workflow for iOS and want to layer AI-assisted parallel development on top, Claudio is purpose-built for exactly that. The iOS development guide in Claudio’s docs covers the tool-specific setup in more detail.
Wrapping Up
Git worktrees aren’t new, and they aren’t iOS-specific. But the iOS ecosystem has enough quirks — DerivedData hashing, simulator state, .pbxproj merge conflicts — that it’s worth knowing how they interact with your Xcode workflow before you dive in. Set up the merge driver, be mindful of simulator contention, and let DerivedData isolation do its thing. The rest is just git.