Never Touch the Human's Checkout
The Problem
An agent switched branches while the human was testing in Xcode, and their next build failed on code they’d never written. Another agent left a half-finished edit behind, and nobody could tell from git status whose it was. A third ran xcodebuild while Xcode was building, and the human’s build died with “database is locked.” The only way back was Clean Build Folder and a full recompile, which is slow for an app with a watch target and a widget target.
All three happened in the same setup. Hello Weather is a one-person product, and that person builds it in Xcode with the repository open all day. Several coding agents work in the same repository at the same time, fixing bugs, editing plans, and updating skills. The human’s copy is never idle. Xcode has files open in it, an incremental build database sits on disk, and a branch is checked out mid-test.
The failures come in two kinds. The working tree has one checked-out branch and one set of files, so two actors writing to it collide. The build has one DerivedData folder, which holds the build database and the compiled output, and Xcode and command-line xcodebuild can’t share it. Neither problem is about the quality of what the agents write. Both are about an agent running next to a person’s live editor. A sibling post covers keeping parallel agents from colliding with each other when they merge. This one is about keeping every agent out of the human’s editor and build state.
The Solution
Give every agent its own checkout and its own build folder, and make that the default rather than something to remember. Four rules:
- Every agent change, down to a one-line doc edit, happens on a branch in a git worktree, created before the first edit. Worktrees live in a gitignored directory inside the repo.
- Agent builds write to a private DerivedData folder. The downloaded packages and the compile cache are shared across worktrees.
- Cleanup follows structure, not habit: a build cache is deleted when its worktree no longer exists.
- One path leads back into the human’s checkout, the QA handoff, and it’s one-way.
Every change goes in a worktree
“Be careful in the shared checkout” is not a rule an agent can follow. A git worktree is a second working directory attached to the same repository. It has its own branch and its own files, and it shares the one object store (the commit history). The human’s checkout and the agent’s worktree can be on different branches, with different uncommitted changes, and never touch each other’s files.
The rule has no exceptions on purpose. No edits in the main checkout at all, not even a one-line plan or doc change, because an agent will talk itself past a rule that allows small changes. The rule lives in AGENTS.md, which every session loads. It landed 2026-08-04 and replaced wording that had allowed branches in the main checkout:
The main checkout is the user’s (Xcode has it open). Never work in it — no edits, no branches, no uncommitted state left behind — unless the user explicitly authorizes it in the current session. All work, including one-line plan/doc edits, happens on a branch in a worktree under
.claude/worktrees/<name>(gitignored; never a sibling of the repo in~/Code/helloweather), created before the first change.
The same PR first tried a PreToolUse hook, a check that runs before each tool call, to block edits outside a worktree. Review dropped it, so instructions enforce the rule, not tooling. It holds because it has no exceptions and every session reads it first.
A worktree that only edits text costs nothing to create. A worktree that has to build needs one more step. Git only writes out tracked files, so the two secret files this project keeps out of git, an API-keys xcconfig and a crash-reporter config, are missing from a fresh worktree. Without them xcodebuild fails right away on the missing base configuration. One script creates the worktree, links the secrets, and sets the build-isolation variables:
#!/bin/bash
set -euo pipefail
REPO="$HOME/Code/app/ios" # the human's checkout (Xcode has this open)
NAME="fix-widget-crash" # short name for this unit of work
WORKTREE="$REPO/.claude/worktrees/$NAME"
# Gitignored secrets git will NOT copy into a fresh worktree. Link, don't copy,
# so a rotated key updates everywhere at once. Names are illustrative.
SECRETS=(
"app/Resources/Secrets.xcconfig"
".crashrc"
)
git -C "$REPO" fetch origin main
git -C "$REPO" worktree add -b "feature/$NAME" "$WORKTREE" origin/main
for rel in "${SECRETS[@]}"; do
mkdir -p "$WORKTREE/$(dirname "$rel")"
ln -sf "$REPO/$rel" "$WORKTREE/$rel"
done
# Build isolation: a private DerivedData so this never shares a build database
# with Xcode. Shared package + compile caches so isolation isn't re-cloning.
export HW_DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData/app-agent"
export HW_SPM_CACHE="$HOME/Library/Caches/app-SPM"
export HW_CAS_PATH="$HOME/Library/Caches/app-CAS"
echo "Worktree ready: $WORKTREE"
The secrets are symlinked, not copied, so they stay gitignored and a rotated key updates every worktree at once. The next section covers the three exported variables. One more habit for agents working in a worktree: use absolute paths in every file operation. A relative path resolves against whatever directory the shell last landed in, and with sibling repos in the workspace that is rarely the worktree you meant.
Isolate the build database, share the caches
The “database is locked” failure has a one-flag fix: -derivedDataPath, set from an environment variable. When the variable is unset, the human’s builds use Xcode’s usual location. When it’s set, the agent builds into a private folder.
Making everything private per worktree would be expensive. Each worktree would download every package again and recompile every file, and a day’s worth of worktrees would spend all day doing that. So we split by what conflicts. The build database is private: the agent’s folder when the variable is set, Xcode’s own per-workspace folder otherwise. The downloaded packages are shared, because they’re just source. The compile cache is shared too, because it’s content-addressed (each compiled file is stored under a hash of its inputs), so a later worktree reuses an earlier one’s compiles instead of redoing them. The cache sharing landed 2026-08-07 through 08. The build script passes all three to xcodebuild. This is the real script trimmed to the flags that matter, with one scheme instead of two:
#!/bin/bash
set -euo pipefail
# Private DerivedData when set (agents/CI); unset keeps Xcode's shared location.
DERIVED_DATA=()
if [ -n "${HW_DERIVED_DATA:-}" ]; then
DERIVED_DATA=(-derivedDataPath "$HW_DERIVED_DATA")
fi
# Shared across worktrees: downloaded packages, and the content-addressed
# compile cache (safe to share; it is not the build database).
SPM_CACHE="${HW_SPM_CACHE:-$HOME/Library/Caches/app-SPM}"
CAS_PATH="${HW_CAS_PATH:-$HOME/Library/Caches/app-CAS}"
xcodebuild build \
-workspace App.xcworkspace \
-scheme AppWidgets \
-destination 'generic/platform=iOS Simulator' \
"${DERIVED_DATA[@]}" \
-clonedSourcePackagesDirPath "$SPM_CACHE" \
-quiet \
COMPILATION_CACHE_ENABLE_CACHING=YES \
COMPILATION_CACHE_CAS_PATH="$CAS_PATH"
One rule the variables can’t enforce: never run two builds at once, even with private DerivedData folders. Two builds still share the downloaded packages and the compile cache, and we don’t want to find out what happens when both write to those together. So each agent has a private place to build, and builds run one at a time.
Cleanup is structural
Every worktree that builds creates roughly 6 GB of DerivedData, and a full private agent cache runs about 8 GB. Ten pieces of work in a day is 60 GB or more of build cache, for changes that may already be merged. Left alone, the disk fills up and every build fails for a reason that has nothing to do with any agent’s code.
A worktree is done once its branch is squash-merged into main. The object store keeps the history, so from then on the working directory and its build cache are waste. The cleanup script prunes git’s records of worktrees that are gone, then deletes DerivedData for any workspace path that no longer exists. Those are the caches removed worktrees left behind, so the script is safe to run at the end of any session. It’s trimmed here; the real one also drops a simulator tool’s caches and unavailable simulators:
#!/bin/bash
set -euo pipefail
REPO="$HOME/Code/app/ios"
echo "Pruning stale worktree records..."
git -C "$REPO" worktree prune
echo "Deleting DerivedData for workspaces that no longer exist..."
for dir in "$HOME/Library/Developer/Xcode/DerivedData/"app-*/; do
[ -d "$dir" ] || continue
workspace="$(/usr/libexec/PlistBuddy -c "Print WorkspacePath" "$dir/info.plist" 2>/dev/null || true)"
if [ -n "$workspace" ] && [ ! -e "$workspace" ]; then
echo " removing $(basename "$dir") ($workspace)"
rm -rf "$dir"
fi
done
df -h / | tail -1 | awk '{print "Free space: " $4}'
The script doesn’t delete by age or by guess. It reads the WorkspacePath each DerivedData folder records and deletes the folder only when that path is gone. An active worktree’s cache stays, and a removed worktree’s cache goes.
The QA handoff is one-way
Everything above keeps agents out of the human’s checkout. There is one moment the human wants a branch in it. When they say “let me test that branch,” they mean in Xcode, in the checkout they already have open, not in a worktree they’d have to go find. That’s the one allowed exception.
The handoff is one-way. Once the branch is in the human’s checkout, that checkout owns it. Follow-up commits happen there. No agent runs a command-line build against it, because that would wreck the live incremental build state in the middle of QA. The checklist, condensed from the pull-requests skill:
## QA handoff (worktree -> human's checkout)
Confirm the handoff with the human first, then:
1. Push the branch. Remove the worktree (the branch persists);
check the branch out in the human's checkout. Displacing whatever
it was on is expected — the human QAs one thing at a time.
2. Open the PR if it isn't already, so the human reviews code on
GitHub while building the branch locally.
3. One-way: from here the human's checkout owns this branch. Follow-up
commits happen there, not in a recreated worktree.
4. Never tell the human to `git pull` for follow-up commits made in
their own checkout — those commits are already in their working copy.
5. Iterate conversationally: propose each follow-up as a readable diff
in chat and get agreement before committing. Approved changes still
push promptly so the PR tracks the conversation.
6. Never run a CLI build (xcodebuild) in the human's checkout after
the handoff — it stomps their live incremental build state. Their
next Xcode build verifies the follow-up; for anything riskier than
a trivial change, say so, or verify in the worktree before handoff.
Item 4 looks trivial and isn’t. An agent once committed a fix in the human’s checkout and then told them to git pull to see it. The commit was already in their working copy, so there was nothing to pull, and the instruction confused them. After the handoff, the checkout is the working copy and the agent is a guest in it.
One smaller rule applies to any pushed branch, handoff or not: never force-push it. Review fixes are new commits, not a rebase that rewrites what someone may have already pulled.
Results
- Branch switches, stray edits, and locked build databases in the human’s checkout used to happen regularly. As of August 2026 they don’t. The worktree rule is loaded in every session rather than remembered.
- The cost is one extra DerivedData cache, 6 to 8 GB, for each worktree that builds. Downloaded packages and compiled files are reused across worktrees instead of repeated.
- The human gets a tested branch in the checkout they already have open, with the PR up for review and no agent build competing with their live one.
Lessons Learned
- Split state by whether it conflicts, not by what it is. The build database conflicts, so it’s private. The downloaded packages and the compile cache are inputs, so they’re shared. Making all of it private would have cost too much disk and time.
- A private build folder doesn’t make parallel builds safe. Builds still share the downloaded packages and the compile cache, so run them one at a time. A rule the tooling can’t enforce goes in the agent instructions.
- A rule with a small-change exception is not a rule. The worktree rule holds because it covers one-line doc edits too, and because it’s in the file every session reads. We tried a hook and dropped it; the wording was enough.
- After a handoff, the human’s working copy is the source of truth, not the branch the agent pushed. The agent doesn’t notice the change on its own; the
git pullwith nothing to pull was the symptom.