Clean Up Xcode Build Data Left Behind by Git Worktrees

Every Git worktree gets its own Xcode DerivedData folder, and removing the worktree leaves it behind. Map folders to worktrees, spot the orphans and delete only those with a tested report script.

Clean Up Xcode Build Data Left Behind by Git Worktrees

Xcode keeps one DerivedData folder per project path and writes that path into the folder's info.plist. Every Git worktree has its own path, so every worktree gets its own build folder, and git worktree remove leaves that folder behind. To clean up safely, read the recorded path in each info.plist, check whether it still exists, and delete only the folders whose path is gone. Matching on the project name deletes the build data of worktrees you are still using.

Quick Take

bash derived-data-report.sh --prefix ~/code/myapp lists each DerivedData folder for that checkout and its myapp-* worktrees, with branch, size and last use, and marks folders whose worktree no longer exists as MISSING. Add --delete-missing to remove only those, after quitting Xcode.

What Xcode creates for each worktree

We built a small Swift package in its main checkout and in a worktree, then listed the DerivedData folders it produced and the WorkspacePath each one recorded:

$ git worktree list
/private/tmp/xcode-worktree-demo/RecipeKit         7798c55 [main]
/private/tmp/xcode-worktree-demo/RecipeKit-feature 7798c55 [feature]

# after building in both checkouts, the DerivedData folders and the path each recorded:
RecipeKit-capvayofxxjhsyfejhfaeucrpbmn          /tmp/xcode-worktree-demo/RecipeKit
RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn          /tmp/xcode-worktree-demo/RecipeKit-feature
RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn  (no info.plist)

Four details from that test matter for any cleanup script:

  • Every worktree's folder starts with the same name. The prefix is the project or package name; only the 28-letter hash differs. A RecipeKit-* wildcard would delete the main checkout's build data along with the removed worktree's.
  • The project path is in info.plist. It also records when Xcode last used the folder:
$ plutil -p ~/Library/Developer/Xcode/DerivedData/RecipeKit-bqfjovskduzgwqeyvymqootabnca/info.plist
{
  "LastAccessedDate" => 2026-11-12 00:40:57 +0000
  "WorkspacePath" => "/tmp/xcode-recipes-demo/RecipeKit-feature"
}
  • A worktree can leave a second folder without an info.plist. Building in the worktree also created RecipeKit-feature-<hash>, named after the worktree's directory and sharing the hash of the recorded folder. It has no info.plist, so a script that only reads info.plist files misses it.
  • Paths under /private are recorded without the prefix. The worktree lived in /private/tmp but Xcode stored /tmp. Compare both forms before calling a path missing.

Xcode 27 also keeps shared caches at the top of DerivedData that belong to no project, such as SDKExplicitPrecompiledModules, ModuleCache.noindex, SDKStatCaches.noindex and CompilationCache.noindex. They have no info.plist either and should never be treated as orphans.

The report labels them separately. From our test Mac:

20 KB      no info.plist              -            Unsaved_Xcode_Document_5-hdhtdidjkkmijrhawizaxvvtoskv
15 MB      shared cache (keep)        -            SDKStatCaches.noindex
532 KB     shared cache (keep)        -            ModuleCache.noindex
400 KB     shared cache (keep)        -            SDKExplicitPrecompiledModules
28 KB      shared cache (keep)        -            CompilationCache.noindex

Map DerivedData folders to worktrees

The derived-data-report.sh script reads every DerivedData folder, follows its recorded path and reports one of these states:

StateMeaningAction
main:<branch>The path is in a repository's main checkoutKeep
worktree:<branch>The path is in a linked worktree that still existsKeep while you use it
existsThe path exists but is not in a Git repositoryKeep
MISSINGThe recorded path is gone, for example after git worktree removeSafe to delete
companion in the last-used columnA folder without info.plist that shares its hash with a recorded folder; it inherits that folder's stateFollows the recorded folder
shared cache (keep)A top-level Xcode cache with no projectKeep
no info.plistA project-style folder with no recorded path and no recorded siblingCheck by hand

It checks the default location, a custom location set in Xcode Settings > Locations, every DerivedData root listed in Xcode's own ~/Library/Developer/Xcode/.knownDerivedDataLocations.log, and any folder you pass with --root. That log is undocumented, so treat it as a hint. On our Mac it listed the default folder (user name replaced):

$ head -c 400 ~/Library/Developer/Xcode/.knownDerivedDataLocations.log
{
  "derivedDataDirectories" : [
    "/Users/you/Library/Developer/Xcode/DerivedData",
    {
      "accessDate" : "2026-11-06T06:15:41Z",
      "appBundlePath" : "/Applications/Xcode.app",
      ...

A repository with one worktree, before it is removed:

$ bash derived-data-report.sh --prefix /tmp/xcode-worktree-demo
SIZE       PROJECT PATH               LAST USED    DERIVED DATA FOLDER -> PROJECT
4 MB       main:main                  Nov 12 2026  RecipeKit-capvayofxxjhsyfejhfaeucrpbmn
                                                     -> /tmp/xcode-worktree-demo/RecipeKit
2 MB       worktree:feature           Nov 12 2026  RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn
                                                     -> /tmp/xcode-worktree-demo/RecipeKit-feature
1 MB       worktree:feature           companion    RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn
                                                     -> /tmp/xcode-worktree-demo/RecipeKit-feature

0 folder(s) point to a project path that no longer exists (0 KB).
Nothing was deleted. Re-run with --delete-missing to remove those folders.

The full script. Copy it, type pbpaste > derived-data-report.sh in Terminal to save it, then run it from any folder:

#!/bin/bash
# derived-data-report.sh
# Maps each Xcode DerivedData folder to the project or Git worktree that
# created it, shows its size and last use, and flags folders whose project
# path no longer exists (for example after `git worktree remove`).
#
# Read-only by default. With --delete-missing it removes only folders whose
# recorded project path is gone, after showing each one and asking first.
#
# Usage:  bash derived-data-report.sh
#         bash derived-data-report.sh --root /path/to/custom/DerivedData
#         bash derived-data-report.sh --prefix ~/code/myapp
#         bash derived-data-report.sh --delete-missing [--yes]
# Guide:  https://appscreens.com/blog/xcode-derived-data-git-worktrees
# Tested: macOS 26.7, Xcode 27.2 (bash 3.2).
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 AppScreens. Free to use, copy and change; please keep
# this header, including the guide link.

set -u
ROOTS=""; PREFIX=""; DELETE=0; YES=0
while [ $# -gt 0 ]; do
  case "$1" in
    --root) ROOTS="$ROOTS
$2"; shift ;;
    --prefix) PREFIX=${2%/}; shift ;;
    --delete-missing) DELETE=1 ;;
    --yes) YES=1 ;;
    -h|--help) sed -n '2,16p' "$0"; exit 0 ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
  shift
done

# DerivedData roots: the default, a custom location set in Xcode settings,
# roots recorded in Xcode's own location log, and any --root you pass.
DEFAULT="$HOME/Library/Developer/Xcode/DerivedData"
CUSTOM=$(defaults read com.apple.dt.Xcode IDECustomDerivedDataLocation 2>/dev/null || true)
KNOWN=$(grep -o '"/[^"]*"' "$HOME/Library/Developer/Xcode/.knownDerivedDataLocations.log" 2>/dev/null | tr -d '"' | grep -v '\.app$' || true)
ROOTS=$(printf "%s\n%s\n%s\n%s\n" "$DEFAULT" "$CUSTOM" "$KNOWN" "$ROOTS" | sed 's:/*$::' | grep -v '^$' | sort -u)

human() { awk -v k="$1" 'BEGIN { if (k >= 1048576) printf "%.1f GB", k / 1048576; else if (k >= 1024) printf "%.0f MB", k / 1024; else printf "%d KB", k }'; }

# Xcode records some paths without the /private prefix (/tmp, /var).
exists() { [ -e "$1" ] || [ -e "/private$1" ]; }

describe() {  # what the recorded project path is today
  local wp=$1 dir top gd cd
  if ! exists "$wp"; then echo "MISSING"; return; fi
  case "$wp" in
    *.xcodeproj|*.xcworkspace|*.playground) dir=$(dirname "$wp") ;;
    *) dir=$wp ;;  # Swift packages record the package folder itself
  esac
  [ -d "$dir" ] || dir="/private$dir"
  top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || { echo "exists"; return; }
  gd=$(cd "$dir" && cd "$(git rev-parse --git-dir)" && pwd -P)
  cd=$(cd "$dir" && cd "$(git rev-parse --git-common-dir)" && pwd -P)
  if [ "$gd" != "$cd" ]; then
    echo "worktree:$(git -C "$dir" branch --show-current 2>/dev/null)"
  else
    echo "main:$(git -C "$dir" branch --show-current 2>/dev/null)"
  fi
}

TMP=$(mktemp -t derived-data-report)
trap 'rm -f "$TMP" "$TMP.2"' EXIT
echo "$ROOTS" | while read -r root; do
  [ -d "$root" ] || continue
  for d in "$root"/*/; do
    d=${d%/}
    [ -d "$d" ] || continue
    wp=""; [ -f "$d/info.plist" ] && wp=$(/usr/libexec/PlistBuddy -c 'Print :WorkspacePath' "$d/info.plist" 2>/dev/null)
    if [ -z "$wp" ]; then
      if [[ $(basename "$d") =~ -[a-z]{28}$ ]]; then state="no info.plist"; else state="shared cache (keep)"; fi
      last="-"
    else
      state=$(describe "$wp")
      last=$(/usr/libexec/PlistBuddy -c 'Print :LastAccessedDate' "$d/info.plist" 2>/dev/null | awk '{print $2, $3, $6}')
    fi
    k=$(du -sk "$d" 2>/dev/null | cut -f1)
    printf "%s\t%s\t%s\t%s\t%s\n" "$k" "$state" "$last" "$d" "${wp:--}" >> "$TMP"
  done
done

# A folder without info.plist that shares its 28-letter hash with a recorded
# folder belongs to the same project (Xcode can create both for one worktree).
# Then keep only projects under --prefix, if one was given.
awk -F'\t' -v OFS='\t' -v prefix="$PREFIX" '
  function hash(path,   n) { n = path; sub(/.*\//, "", n); sub(/.*-/, "", n); return n }
  NR == FNR { if ($5 != "-") { st[hash($4)] = $2; wp[hash($4)] = $5 } next }
  {
    h = hash($4)
    if ($2 == "no info.plist" && length(h) == 28 && (h in st)) { $2 = st[h]; $3 = "companion"; $5 = wp[h] }
    if (prefix != "") {
      p2 = prefix; sub(/^\/private/, "", p2)
      if ($5 == "-" || (index($5, prefix) != 1 && index($5, p2) != 1 && index($5, "/private" p2) != 1)) next
    }
    print
  }' "$TMP" "$TMP" > "$TMP.2" && mv "$TMP.2" "$TMP"

if [ ! -s "$TMP" ]; then echo "No DerivedData folders found."; exit 0; fi

printf "%-9s  %-26s %-12s %s\n" "SIZE" "PROJECT PATH" "LAST USED" "DERIVED DATA FOLDER -> PROJECT"
sort -t$'\t' -k2,2 -k1,1rn "$TMP" | while IFS=$'\t' read -r k state last d wp; do
  printf "%-9s  %-26s %-12s %s\n" "$(human "$k")" "$state" "$last" "$(basename "$d")"
  [ "$wp" != "-" ] && printf "%-9s  %-26s %-12s   -> %s\n" "" "" "" "$wp"
done
miss=$(awk -F'\t' '$2 == "MISSING" { n++; k += $1 } END { printf "%d %d", n, k }' "$TMP")
printf "\n%s folder(s) point to a project path that no longer exists (%s).\n" "${miss% *}" "$(human "${miss#* }")"

[ $DELETE -eq 1 ] || { echo "Nothing was deleted. Re-run with --delete-missing to remove those folders."; exit 0; }
if pgrep -xq Xcode; then echo "Xcode is running. Quit Xcode first so it does not recreate or hold these folders." >&2; exit 1; fi
awk -F'\t' '$2 == "MISSING" { print $4 }' "$TMP" | while read -r d; do
  case "$(basename "$d")" in *-[a-z]*) ;; *) echo "Skipping unexpected name: $d"; continue ;; esac
  if [ $YES -eq 0 ]; then
    printf "Delete %s? [y/N] " "$d"; read -r answer < /dev/tty
    [ "$answer" = "y" ] || { echo "kept"; continue; }
  fi
  rm -rf "$d" && echo "deleted $d"
done

Clean up after removing a worktree

  1. Remove the worktree with git worktree remove ../myapp-feature. If you deleted its folder by hand, run git worktree prune so Git forgets it.
  2. Quit Xcode. The script refuses to delete while Xcode is running, so Xcode cannot recreate or hold a folder mid-cleanup.
  3. Report the repository with --prefix set to the start of its path. It is a plain string match, so ~/code/myapp also covers worktrees such as ~/code/myapp-feature. Only MISSING folders are ever deleted, so a wider prefix is safe.
  4. Delete only MISSING folders with --delete-missing. Without --yes it asks for each folder.
  5. Verify by running the report again.
git worktree remove ../myapp-feature
bash derived-data-report.sh --prefix ~/code/myapp

Then delete, answering y for each folder you want gone:

bash derived-data-report.sh --prefix ~/code/myapp --delete-missing

After git worktree remove, the worktree's folder and its companion are MISSING and the main checkout is untouched:

$ git worktree remove ../RecipeKit-feature
$ bash derived-data-report.sh --prefix /tmp/xcode-worktree-demo
SIZE       PROJECT PATH               LAST USED    DERIVED DATA FOLDER -> PROJECT
4 MB       main:main                  Nov 12 2026  RecipeKit-capvayofxxjhsyfejhfaeucrpbmn
                                                     -> /tmp/xcode-worktree-demo/RecipeKit
2 MB       MISSING                    Nov 12 2026  RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn
                                                     -> /tmp/xcode-worktree-demo/RecipeKit-feature
1 MB       MISSING                    companion    RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn
                                                     -> /tmp/xcode-worktree-demo/RecipeKit-feature

2 folder(s) point to a project path that no longer exists (4 MB).
Nothing was deleted. Re-run with --delete-missing to remove those folders.

With Xcode still open, the script stops before deleting anything:

$ bash derived-data-report.sh --prefix /tmp/xcode-worktree-demo --delete-missing --yes
...
2 folder(s) point to a project path that no longer exists (4 MB).
Xcode is running. Quit Xcode first so it does not recreate or hold these folders.

With Xcode closed, it deletes only the MISSING folders. In this example both checkouts of the repository had been deleted:

$ bash derived-data-report.sh --prefix /tmp/xcode-recipes-demo --delete-missing --yes
SIZE       PROJECT PATH               LAST USED    DERIVED DATA FOLDER -> PROJECT
4 MB       MISSING                    Nov 12 2026  RecipeKit-gtludwkteqprjcdonxdqhuhzznbd
                                                     -> /tmp/xcode-recipes-demo/RecipeKit
2 MB       MISSING                    Nov 12 2026  RecipeKit-bqfjovskduzgwqeyvymqootabnca
                                                     -> /tmp/xcode-recipes-demo/RecipeKit-feature

2 folder(s) point to a project path that no longer exists (6 MB).
deleted /Users/you/Library/Developer/Xcode/DerivedData/RecipeKit-bqfjovskduzgwqeyvymqootabnca
deleted /Users/you/Library/Developer/Xcode/DerivedData/RecipeKit-gtludwkteqprjcdonxdqhuhzznbd

$ bash derived-data-report.sh --prefix /tmp/xcode-recipes-demo
No DerivedData folders found.

Without the script

This loop prints every DerivedData folder whose recorded project path no longer exists. It deletes nothing; review the list first:

for p in ~/Library/Developer/Xcode/DerivedData/*/info.plist; do
  wp=$(/usr/libexec/PlistBuddy -c 'Print :WorkspacePath' "$p" 2>/dev/null) || continue
  [ -e "$wp" ] || [ -e "/private$wp" ] || echo "$(dirname "$p")  ->  $wp"
done

To find the folder for the checkout you are in, search the info.plist files for its path: grep -l "$PWD" ~/Library/Developer/Xcode/DerivedData/*/info.plist. Worktrunk's DerivedData recipe uses the same idea in a post-remove hook. A plain substring match also matches longer paths that start the same way, such as myapp-feature and myapp-feature-2, so compare the recorded path exactly before deleting, and look for a companion folder with the same hash.

Both shortcuts have gaps we hit in testing:

# the loop lists the recorded folder of the removed worktree...
/Users/you/Library/Developer/Xcode/DerivedData/RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn  ->  /tmp/xcode-worktree-demo/RecipeKit-feature
# ...but not its companion, RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn, which has no info.plist.

$ cd /private/tmp/xcode-worktree-demo/RecipeKit-feature
$ grep -l "$PWD" ~/Library/Developer/Xcode/DerivedData/*/info.plist
$ echo $?
1
# no match: Xcode recorded /tmp/..., the shell reports /private/tmp/...

Keep build data inside each worktree

If you build from the command line or a coding agent does, point DerivedData inside the worktree. Removing the worktree then removes its build data too:

$ git worktree add -q ../RecipeKit-local -b local
$ cd ../RecipeKit-local
$ xcodebuild -scheme RecipeKit -destination 'generic/platform=macOS' -derivedDataPath .build/DerivedData -quiet build
$ du -sh .build/DerivedData
 19M	.build/DerivedData
$ cd ../RecipeKit
$ git worktree remove --force ../RecipeKit-local

The example uses --force because .build is untracked there, and git worktree remove refuses to remove a worktree with untracked files. Add .build/ to .gitignore instead: ignored files do not count, so the worktree and its build data go without --force, and uncommitted work still blocks the removal. In the Xcode app, Settings > Locations > Derived Data can be set to Relative, which keeps each workspace's build data next to it.

Running several agents or branches in parallel? Each worktree's first build is a full build, which is the cost of isolation. Xcode 27 adds a Product > Delete Derived Data menu item, which Apple covered in the WWDC26 Xcode Tips and Tricks group lab. The report above is the targeted alternative when you only want to remove what deleted worktrees left behind.

FAQ

Does git worktree remove delete Xcode DerivedData?

No. Xcode stores build data in ~/Library/Developer/Xcode/DerivedData, outside the worktree, so removing the worktree leaves its folder there until you delete it.

How do I know which DerivedData folder belongs to which worktree?

Each folder's info.plist records the project path as WorkspacePath. Print it with plutil -p or PlistBuddy, or run derived-data-report.sh, which also shows the worktree branch and whether the path still exists.

Can I delete DerivedData folders by project name?

No. Every worktree of a repository gets a folder that starts with the same project name, so a wildcard deletes the build data of worktrees you still use. Match on the recorded path instead.

Why is there a DerivedData folder without info.plist?

Xcode keeps shared caches at the top of DerivedData, such as SDKExplicitPrecompiledModules and ModuleCache.noindex, which belong to no project. In our test a worktree build also left a companion folder named after the worktree directory, sharing the hash of the recorded folder.

How do I stop worktrees from filling DerivedData?

Build with xcodebuild -derivedDataPath .build/DerivedData inside the worktree and add .build/ to .gitignore, or set Derived Data to Relative in Xcode Settings > Locations. git worktree remove ignores Git-ignored files, so the build data is removed with the worktree.

Quote this guide

You can quote sections, tables, commands and charts from this guide in your own posts, documentation or answers, as long as you link to this page. Please do not republish the whole guide. The scripts are MIT licensed, so you can copy and change them freely.

AppScreens.com, "Clean Up Xcode Build Data Left Behind by Git Worktrees", 3 November 2026. https://appscreens.com/blog/xcode-derived-data-git-worktrees

Sources

Tested on macOS 26.7.2 with Xcode 27.2 (27B52). Apple does not document DerivedData's folder naming or info.plist, and either can change between Xcode versions.

Turn raw app screens into installs

Make your app value obvious with professional, localized, store-ready screenshots for every required app store size. Trusted by 200,000+ app professionals, with 13M+ screenshots exported. Start free.

Browse templates