Skip to content

Instantly share code, notes, and snippets.

@skooch
Last active April 25, 2026 01:45
Show Gist options
  • Select an option

  • Save skooch/31e20abeeaa3144a6eb4368942f9997f to your computer and use it in GitHub Desktop.

Select an option

Save skooch/31e20abeeaa3144a6eb4368942f9997f to your computer and use it in GitHub Desktop.
XCode Clean Up Script
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# XCode Cache, Simulator & SDK Cleanup Script
# by @skooch
# =============================================================================
#
# Removes old SDKs, simulators, simulator images, caches, and device support
# files while preserving only what's needed for the deployment target and the
# latest Xcode SDK (removing everything in between).
#
# Usage:
# ./scripts/cleanup-simulators.sh # Dry run (default)
# ./scripts/cleanup-simulators.sh --execute # Actually delete
# ./scripts/cleanup-simulators.sh --info # Show space usage only
# ./scripts/cleanup-simulators.sh --help # Show help
#
# Override auto-detected versions:
# --ios-min=16 Override iOS deployment target version
# --ios-max=18 Override iOS latest SDK version
# --watchos-min=10 Override watchOS deployment target version
# --watchos-max=11 Override watchOS latest SDK version
# --ios-versions=18.0,26.2 Explicit iOS versions to keep (overrides min/max)
# --watchos-versions=10.6,26.2
# Explicit watchOS versions to keep (overrides min/max)
#
# Directory reference (~/Library/Developer/):
#
# Xcode/
# DerivedData/ Build intermediates, indexes. Regenerated on build.
# Archives/ .xcarchive bundles from Product > Archive.
# iOS DeviceSupport/ Debug symbols for physical iOS devices by version.
# watchOS DeviceSupport/ Debug symbols for physical watchOS devices by version.
# iOS Device Logs/ Crash logs pulled from physical devices.
# Products/ Exported .ipa files.
# UserData/Previews/ SwiftUI preview simulator data.
# UserData/IB/ Interface Builder sandbox simulators.
#
# CoreSimulator/
# Devices/ Per-simulator data (apps, media, keychain, etc.).
# Each subfolder is a UUID matching a booted device.
# Caches/ Shared runtime caches across simulators.
#
# /Library/Developer/CoreSimulator/ (system-level, requires sudo)
# Images/ Downloadable .dmg disk images for sim runtimes.
# Profiles/Runtimes/ Mounted .simruntime bundles (iOS 17.simruntime, etc.).
# Cryptex/Images/ Cryptex-based runtime images (newer Xcode).
#
# XCPGDevices/ Xcode Playground simulator data.
# XCTestDevices/ Ephemeral XCTest device artifacts (per-device UUID folders).
#
# Caches:
# ~/Library/Caches/com.apple.dt.Xcode Xcode module/index caches.
#
# Logs:
# ~/Library/Logs/CoreSimulator/ Simulator syslog + NSLog output.
#
# =============================================================================
# --- Configuration -----------------------------------------------------------
DRY_RUN=true
VERBOSE=false
INFO_ONLY=false
NUKE_EVERYTHING=false
INTERACTIVE=false
TOTAL_FREED=0
# Override values (empty = auto-detect)
OVERRIDE_IOS_MIN=""
OVERRIDE_IOS_MAX=""
OVERRIDE_WATCHOS_MIN=""
OVERRIDE_WATCHOS_MAX=""
OVERRIDE_IOS_VERSIONS=""
OVERRIDE_WATCHOS_VERSIONS=""
PREFERRED_MODELS_CSV=""
PREFERRED_MODELS_EXPLICIT=false
EXCLUDED_PHASES_CSV=""
VALID_EXCLUDED_PHASES_CSV="shutdown-simulators,delete-unavailable-simulators,auxiliary-simulators,runtime-cleanup,non-kept-runtime-devices,device-model-trim,ios-device-support,watchos-device-support,derived-data,archives,ios-device-logs,core-simulator-cache-logs,xctestdevices-dead,xcode-caches,playground-data,swiftui-preview,spm-caches"
# Full versions set by auto-detection or overrides (e.g. 18.0, 26.2)
IOS_MIN_VERSION=""
IOS_MAX_VERSION=""
WATCHOS_MIN_VERSION=""
WATCHOS_MAX_VERSION=""
# Comma-separated normalized version sets that determine what to keep.
KEEP_IOS_VERSIONS_CSV=""
KEEP_WATCHOS_VERSIONS_CSV=""
# --- Colors ------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
# --- Helpers -----------------------------------------------------------------
log() { echo -e "${CYAN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_ok() { echo -e "${GREEN}[ OK ]${NC} $*"; }
log_del() { echo -e "${RED}[DEL ]${NC} $*"; }
log_skip() { echo -e "${BLUE}[SKIP]${NC} $*"; }
log_head() { echo -e "\n${BOLD}=== $* ===${NC}"; }
log_size() { echo -e "${DIM}[SIZE]${NC} $*"; }
usage() {
echo "Usage: $(basename "$0") [OPTIONS]"
echo ""
echo "Options:"
echo " --execute Actually delete files (default is dry run)"
echo " --everything Remove ALL simulators, runtimes, SDKs, caches - nothing kept"
echo " --interactive Prompt for each runtime keep/delete decision"
echo " --perferred-models=LIST"
echo " Comma-separated simulator models to keep/create"
echo " (exact names from simctl device types)"
echo " --ios-versions=LIST"
echo " Comma-separated iOS versions to keep installed"
echo " (overrides --ios-min and --ios-max)"
echo " --watchos-versions=LIST"
echo " Comma-separated watchOS versions to keep installed"
echo " (overrides --watchos-min and --watchos-max)"
echo " --excluded=LIST Comma-separated phase IDs to skip during cleanup"
echo " --info Show space usage for each category (no deletions)"
echo " --verbose Show extra detail"
echo " --help Show this help"
echo ""
echo "Version overrides (default: auto-detected from project & Xcode):"
echo " --ios-min=VER iOS deployment target version (e.g. 18.0 or 18)"
echo " --ios-max=VER iOS latest SDK version (e.g. 26.2 or 26)"
echo " --watchos-min=VER watchOS deployment target version (e.g. 11.0 or 11)"
echo " --watchos-max=VER watchOS latest SDK version (e.g. 26.2 or 26)"
echo ""
echo "By default the script keeps runtimes for deployment target + latest SDK."
echo "Use --ios-versions/--watchos-versions to keep an explicit version set."
echo "Without --perferred-models, the script defaults to latest flagship"
echo "iPhone + Apple Watch simulator models."
echo ""
echo "With --everything, ALL simulator runtimes, devices, SDKs, caches, and"
echo "support files are removed regardless of version. Use with --execute to"
echo "actually delete."
echo ""
echo "Valid options for --perferred-models on this machine:"
print_valid_preferred_models
echo ""
echo "Valid options for --excluded:"
print_valid_excluded_phases
echo ""
echo "Without --execute, the script only reports what it would delete."
exit "${1:-0}"
}
dir_size_bytes() {
if [[ -d "$1" ]]; then
du -sk "$1" 2>/dev/null | awk '{print $1 * 1024}' || echo 0
else
echo 0
fi
}
dir_size_human() {
if [[ -d "$1" ]]; then
du -sh "$1" 2>/dev/null | awk '{print $1}' || echo "0B"
else
echo "0B"
fi
}
dir_size_bytes_sudo() {
if [[ -e "$1" ]]; then
sudo du -sk "$1" 2>/dev/null | awk '{print $1 * 1024}' || echo 0
else
echo 0
fi
}
dir_size_human_sudo() {
if [[ -e "$1" ]]; then
sudo du -sh "$1" 2>/dev/null | awk '{print $1}' || echo "0B"
else
echo "0B"
fi
}
bytes_to_human() {
local bytes="$1"
if [[ "$bytes" -gt $((1024 * 1024 * 1024)) ]]; then
echo "$(echo "scale=1; $bytes / 1073741824" | bc) GB"
elif [[ "$bytes" -gt $((1024 * 1024)) ]]; then
echo "$(echo "scale=1; $bytes / 1048576" | bc) MB"
elif [[ "$bytes" -gt 1024 ]]; then
echo "$(echo "scale=1; $bytes / 1024" | bc) KB"
else
echo "${bytes} bytes"
fi
}
remove_path() {
local path="$1"
local label="${2:-$path}"
if [[ ! -e "$path" ]]; then
return
fi
local size
size=$(dir_size_human "$path")
local size_bytes
size_bytes=$(dir_size_bytes "$path")
if $INFO_ONLY; then
return
fi
if $DRY_RUN; then
log_del "(dry run) Would remove: ${label} [${size}]"
else
rm -rf "$path"
log_del "Removed: ${label} [${size}]"
fi
TOTAL_FREED=$((TOTAL_FREED + size_bytes))
}
sudo_remove_path() {
local path="$1"
local label="${2:-$path}"
if [[ ! -e "$path" ]]; then
return
fi
local size
size=$(dir_size_human "$path")
if [[ "$size" == "0B" ]]; then
size=$(dir_size_human_sudo "$path")
fi
local size_bytes
size_bytes=$(dir_size_bytes "$path")
if [[ "$size_bytes" -eq 0 ]]; then
size_bytes=$(dir_size_bytes_sudo "$path")
fi
if $INFO_ONLY; then
return
fi
if $DRY_RUN; then
log_del "(dry run) Would sudo remove: ${label} [${size}]"
else
if sudo rm -rf "$path" 2>/dev/null; then
log_del "Removed (sudo): ${label} [${size}]"
else
log_warn "Could not remove ${label} (SIP-protected or in use) - skipping"
return 0
fi
fi
TOTAL_FREED=$((TOTAL_FREED + size_bytes))
}
run_cmd() {
local desc="$1"
shift
if $INFO_ONLY; then
return
fi
if $DRY_RUN; then
log_del "(dry run) ${desc}: $*"
else
log "Running: ${desc}"
"$@" 2>/dev/null || true
fi
}
confirm_runtime_delete() {
local platform="$1"
local version="$2"
local default_delete="$3"
local prompt default_choice choice delete_runtime
if ! $INTERACTIVE; then
$default_delete
return
fi
if $default_delete; then
default_choice="Y/n"
prompt="Delete runtime ${platform} ${version}? [${default_choice}] "
else
default_choice="y/N"
prompt="Delete runtime ${platform} ${version}? [${default_choice}] "
fi
read -r -p "$prompt" choice
if [[ -z "$choice" ]]; then
$default_delete
return
fi
if [[ "$choice" =~ ^[Yy]$ ]]; then
delete_runtime=true
else
delete_runtime=false
fi
$delete_runtime
}
# Unmount a simulator runtime disk image before deletion.
# .simruntime bundles and .dmg images may be mounted; deleting while mounted
# causes "resource busy" errors.
unmount_image() {
local path="$1"
if $INFO_ONLY; then
return
fi
# Check if this path (or a volume associated with it) is mounted
local mount_point=""
# For .simruntime bundles, check if any volume is mounted from this path
if [[ "$path" == *.simruntime ]]; then
mount_point=$(hdiutil info 2>/dev/null | grep -B 10 "$path" | grep '/Volumes/' | awk '{print $NF}' || true)
fi
# Also check if the path itself or its parent is a mount point
if [[ -z "$mount_point" ]]; then
mount_point=$(mount 2>/dev/null | grep "$path" | awk '{print $3}' || true)
fi
if [[ -n "$mount_point" ]]; then
if $DRY_RUN; then
log "(dry run) Would unmount: ${mount_point}"
else
log "Unmounting: ${mount_point}"
hdiutil detach "$mount_point" -force 2>/dev/null || \
diskutil unmount force "$mount_point" 2>/dev/null || \
log_warn "Failed to unmount ${mount_point} - deletion may fail"
fi
fi
# For .dmg files, check if they're attached
if [[ "$path" == *.dmg ]]; then
local dev_node
dev_node=$(hdiutil info 2>/dev/null | grep -B 20 "$(basename "$path")" | grep '/dev/disk' | head -1 | awk '{print $1}' || true)
if [[ -n "$dev_node" ]]; then
if $DRY_RUN; then
log "(dry run) Would detach disk image: $(basename "$path")"
else
log "Detaching disk image: $(basename "$path")"
hdiutil detach "$dev_node" -force 2>/dev/null || \
log_warn "Failed to detach $(basename "$path") - deletion may fail"
fi
fi
fi
}
# Normalize a version string to dot-separated numeric components and
# trim trailing ".0" groups so 18, 18.0, and 18.0.0 compare equally.
normalize_version() {
local input="$1"
local cleaned parts normalized i
cleaned=$(echo "$input" | tr '_-' '.' | sed -E 's/[^0-9.]+/./g; s/\.\.+/./g; s/^\.//; s/\.$//')
if [[ -z "$cleaned" ]]; then
echo ""
return
fi
IFS='.' read -r -a parts <<< "$cleaned"
normalized=()
for i in "${parts[@]}"; do
[[ "$i" =~ ^[0-9]+$ ]] || continue
normalized+=("$((10#$i))")
done
if [[ "${#normalized[@]}" -eq 0 ]]; then
echo ""
return
fi
while [[ "${#normalized[@]}" -gt 1 ]]; do
local last_index=$(( ${#normalized[@]} - 1 ))
[[ "${normalized[$last_index]}" -eq 0 ]] || break
unset "normalized[$last_index]"
done
(IFS='.'; echo "${normalized[*]}")
}
versions_equal() {
local a b
a=$(normalize_version "$1")
b=$(normalize_version "$2")
[[ -n "$a" && -n "$b" && "$a" == "$b" ]]
}
# Check if version $1 is strictly greater than $2 (using sort -V).
version_gt() {
local a b
a=$(normalize_version "$1")
b=$(normalize_version "$2")
[[ -n "$a" && -n "$b" && "$a" != "$b" ]] || return 1
local highest
highest=$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -1)
[[ "$highest" == "$a" ]]
}
# Check if two versions share the same major.minor (e.g. 26.4 matches 26.4.1).
versions_match_minor() {
local a b
a=$(normalize_version "$1")
b=$(normalize_version "$2")
[[ -n "$a" && -n "$b" ]] || return 1
# Truncate both to major.minor for comparison
local a_minor b_minor
a_minor=$(echo "$a" | cut -d. -f1-2)
b_minor=$(echo "$b" | cut -d. -f1-2)
[[ "$a_minor" == "$b_minor" ]]
}
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
echo "$value"
}
csv_contains_exact() {
local csv="$1"
local needle="$2"
local entry
[[ -z "$csv" ]] && return 1
IFS=',' read -r -a entries <<< "$csv"
for entry in "${entries[@]}"; do
[[ "$entry" == "$needle" ]] && return 0
done
return 1
}
csv_append_unique_exact() {
local csv="$1"
local value="$2"
if [[ -z "$value" ]]; then
echo "$csv"
return
fi
if csv_contains_exact "$csv" "$value"; then
echo "$csv"
elif [[ -z "$csv" ]]; then
echo "$value"
else
echo "${csv},${value}"
fi
}
csv_contains_version() {
local csv="$1"
local needle="$2"
local entry
[[ -z "$csv" ]] && return 1
IFS=',' read -r -a entries <<< "$csv"
for entry in "${entries[@]}"; do
versions_equal "$entry" "$needle" && return 0
done
return 1
}
csv_append_unique_version() {
local csv="$1"
local value="$2"
if [[ -z "$value" ]]; then
echo "$csv"
return
fi
if csv_contains_version "$csv" "$value"; then
echo "$csv"
elif [[ -z "$csv" ]]; then
echo "$value"
else
echo "${csv},${value}"
fi
}
format_csv_for_display() {
local csv="$1"
if [[ -z "$csv" ]]; then
echo "(none)"
else
echo "${csv//,/, }"
fi
}
parse_version_csv() {
local raw="$1"
local parsed=""
local token trimmed normalized
IFS=',' read -r -a tokens <<< "$raw"
for token in "${tokens[@]}"; do
trimmed=$(trim_whitespace "$token")
[[ -z "$trimmed" ]] && continue
normalized=$(normalize_version "$trimmed")
if [[ -z "$normalized" ]]; then
log_warn "Ignoring invalid version in list: ${trimmed}"
continue
fi
parsed=$(csv_append_unique_version "$parsed" "$normalized")
done
echo "$parsed"
}
parse_models_csv() {
local raw="$1"
local parsed=""
local token trimmed
IFS=',' read -r -a tokens <<< "$raw"
for token in "${tokens[@]}"; do
trimmed=$(trim_whitespace "$token")
[[ -z "$trimmed" ]] && continue
parsed=$(csv_append_unique_exact "$parsed" "$trimmed")
done
echo "$parsed"
}
normalize_phase_id() {
local phase="$1"
phase=$(trim_whitespace "$phase")
phase=$(echo "$phase" | tr '[:upper:]' '[:lower:]' | tr '_ ' '--')
echo "$phase"
}
parse_excluded_phases_csv() {
local raw="$1"
local parsed=""
local token normalized
IFS=',' read -r -a tokens <<< "$raw"
for token in "${tokens[@]}"; do
normalized=$(normalize_phase_id "$token")
[[ -z "$normalized" ]] && continue
parsed=$(csv_append_unique_exact "$parsed" "$normalized")
done
echo "$parsed"
}
phase_is_excluded() {
local phase="$1"
local normalized
normalized=$(normalize_phase_id "$phase")
[[ -n "$normalized" ]] && csv_contains_exact "$EXCLUDED_PHASES_CSV" "$normalized"
}
print_valid_excluded_phases() {
local phase
IFS=',' read -r -a phases <<< "$VALID_EXCLUDED_PHASES_CSV"
for phase in "${phases[@]}"; do
echo " - ${phase}"
done
}
validate_excluded_phases() {
if [[ -z "$EXCLUDED_PHASES_CSV" ]]; then
return
fi
local phase
IFS=',' read -r -a phases <<< "$EXCLUDED_PHASES_CSV"
for phase in "${phases[@]}"; do
if ! csv_contains_exact "$VALID_EXCLUDED_PHASES_CSV" "$phase"; then
echo "Error: invalid phase in --excluded: ${phase}" >&2
echo "Run with --help to see valid phase IDs." >&2
exit 1
fi
done
}
begin_phase() {
local phase_id="$1"
local title="$2"
log_head "$title"
if phase_is_excluded "$phase_id"; then
log_skip "Excluded phase: ${phase_id}"
return 1
fi
return 0
}
should_keep_platform_version() {
local platform="$1"
local version="$2"
local keep_csv entry
if $NUKE_EVERYTHING; then
return 1
fi
if [[ "$platform" == "ios" ]]; then
keep_csv="$KEEP_IOS_VERSIONS_CSV"
else
keep_csv="$KEEP_WATCHOS_VERSIONS_CSV"
fi
[[ -z "$keep_csv" ]] && return 1
IFS=',' read -r -a entries <<< "$keep_csv"
for entry in "${entries[@]}"; do
versions_equal "$version" "$entry" && return 0
done
return 1
}
print_valid_preferred_models() {
if ! command -v xcrun &>/dev/null || ! command -v python3 &>/dev/null; then
echo " (xcrun/python3 not available to enumerate device models)"
return
fi
local devicetypes_json
if ! devicetypes_json=$(xcrun simctl list devicetypes --json 2>/dev/null); then
echo " (unable to query simulator device types on this machine)"
return
fi
local models
models=$(echo "$devicetypes_json" | python3 -c "
import sys, json
data = json.load(sys.stdin)
names = set()
for dt in data.get('devicetypes', []):
ident = dt.get('identifier', '')
name = dt.get('name', '')
if '.iPhone-' in ident or '-Watch-' in ident:
names.add(name)
for name in sorted(names):
print(name)
" 2>/dev/null || true)
if [[ -z "$models" ]]; then
echo " (no iPhone/Apple Watch simulator models found)"
return
fi
while IFS= read -r model; do
[[ -z "$model" ]] && continue
echo " - ${model}"
done <<< "$models"
}
resolve_preferred_model_devicetype() {
local model="$1"
xcrun simctl list devicetypes --json 2>/dev/null | python3 -c "
import sys, json
model = sys.argv[1]
data = json.load(sys.stdin)
for dt in data.get('devicetypes', []):
ident = dt.get('identifier', '')
name = dt.get('name', '')
if name != model:
continue
if '.iPhone-' in ident:
print(f'{ident}|ios')
raise SystemExit(0)
if '-Watch-' in ident:
print(f'{ident}|watchos')
raise SystemExit(0)
" "$model" 2>/dev/null || true
}
model_is_preferred() {
local model="$1"
if [[ -z "$PREFERRED_MODELS_CSV" ]]; then
return 1
fi
csv_contains_exact "$PREFERRED_MODELS_CSV" "$model"
}
runtime_ids_for_platform() {
local platform="$1"
xcrun simctl list runtimes --json 2>/dev/null | python3 -c "
import sys, json
platform = sys.argv[1]
data = json.load(sys.stdin)
for runtime in data.get('runtimes', []):
avail = runtime.get('isAvailable')
if avail is False:
continue
ident = runtime.get('identifier', '')
version = runtime.get('version', '')
if platform == 'ios' and '.iOS-' not in ident:
continue
if platform == 'watchos' and '.watchOS-' not in ident:
continue
print(f'{ident}|{version}')
" "$platform" 2>/dev/null || true
}
detect_default_preferred_models() {
if ! command -v xcrun &>/dev/null || ! command -v python3 &>/dev/null; then
echo ""
return
fi
local devicetypes_json
if ! devicetypes_json=$(xcrun simctl list devicetypes --json 2>/dev/null); then
echo ""
return
fi
echo "$devicetypes_json" | python3 -c "
import json
import re
import sys
data = json.load(sys.stdin)
iphone_best = None
watch_best = None
for dt in data.get('devicetypes', []):
ident = dt.get('identifier', '')
name = dt.get('name', '')
# Prefer latest iPhone Pro tier; fallback across available tiers.
m_iphone = re.search(r'iPhone-(\d+)(.*)$', ident)
if m_iphone:
gen = int(m_iphone.group(1))
suffix = m_iphone.group(2)
if '-SE' in suffix:
pass
else:
if '-Pro' in suffix and '-Pro-Max' not in suffix:
tier = 4
elif '-Pro-Max' in suffix:
tier = 3
elif '-Plus' in suffix:
tier = 2
else:
tier = 1
rank = (gen, tier)
if iphone_best is None or rank > iphone_best[0]:
iphone_best = (rank, name)
# Prefer latest Apple Watch Series, largest case size.
m_watch = re.search(r'Watch-Series-(\d+)(?:-(\d+))?mm?$', ident)
if m_watch:
series = int(m_watch.group(1))
size = int(m_watch.group(2) or 0)
rank = (series, size)
if watch_best is None or rank > watch_best[0]:
watch_best = (rank, name)
models = []
if iphone_best and watch_best:
models.append(iphone_best[1])
models.append(watch_best[1])
print(','.join(models))
" 2>/dev/null || true
}
highest_targeted_version() {
local platform="$1"
local keep_csv
if [[ "$platform" == "ios" ]]; then
keep_csv="$KEEP_IOS_VERSIONS_CSV"
else
keep_csv="$KEEP_WATCHOS_VERSIONS_CSV"
fi
echo "$keep_csv" | tr ',' '\n' | sed '/^$/d' | sort -V | tail -1
}
runtime_id_for_platform_version() {
local platform="$1"
local target_version="$2"
local runtime_line runtime_id runtime_version
while IFS= read -r runtime_line; do
[[ -z "$runtime_line" ]] && continue
runtime_id="${runtime_line%%|*}"
runtime_version="${runtime_line##*|}"
versions_equal "$runtime_version" "$target_version" || continue
echo "$runtime_id"
return
done < <(runtime_ids_for_platform "$platform")
echo ""
}
model_has_device_on_kept_versions() {
local model="$1"
local platform="$2"
local runtime_line runtime_id runtime_version
while IFS= read -r runtime_line; do
[[ -z "$runtime_line" ]] && continue
runtime_id="${runtime_line%%|*}"
runtime_version="${runtime_line##*|}"
should_keep_platform_version "$platform" "$runtime_version" || continue
if device_exists_for_model_runtime "$model" "$runtime_id"; then
return 0
fi
done < <(runtime_ids_for_platform "$platform")
return 1
}
available_runtime_versions_csv() {
local platform="$1"
local available_versions=""
local runtime_line runtime_version
while IFS= read -r runtime_line; do
[[ -z "$runtime_line" ]] && continue
runtime_version="${runtime_line##*|}"
runtime_version=$(normalize_version "$runtime_version")
available_versions=$(csv_append_unique_version "$available_versions" "$runtime_version")
done < <(runtime_ids_for_platform "$platform")
echo "$available_versions"
}
device_exists_for_model_runtime() {
local model="$1"
local runtime_id="$2"
xcrun simctl list devices --json 2>/dev/null | python3 -c "
import sys, json
model = sys.argv[1]
runtime_id = sys.argv[2]
data = json.load(sys.stdin)
for dev in data.get('devices', {}).get(runtime_id, []):
if dev.get('isAvailable') is False:
continue
if dev.get('name') == model:
raise SystemExit(0)
raise SystemExit(1)
" "$model" "$runtime_id" 2>/dev/null
}
configure_keep_versions() {
KEEP_IOS_VERSIONS_CSV=""
KEEP_WATCHOS_VERSIONS_CSV=""
if [[ -n "$OVERRIDE_IOS_VERSIONS" ]]; then
KEEP_IOS_VERSIONS_CSV=$(parse_version_csv "$OVERRIDE_IOS_VERSIONS")
if [[ -z "$KEEP_IOS_VERSIONS_CSV" ]]; then
echo "Error: --ios-versions did not include any valid versions" >&2
exit 1
fi
else
KEEP_IOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_IOS_VERSIONS_CSV" "$(normalize_version "$IOS_MIN_VERSION")")
KEEP_IOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_IOS_VERSIONS_CSV" "$(normalize_version "$IOS_MAX_VERSION")")
fi
if [[ -n "$OVERRIDE_WATCHOS_VERSIONS" ]]; then
KEEP_WATCHOS_VERSIONS_CSV=$(parse_version_csv "$OVERRIDE_WATCHOS_VERSIONS")
if [[ -z "$KEEP_WATCHOS_VERSIONS_CSV" ]]; then
echo "Error: --watchos-versions did not include any valid versions" >&2
exit 1
fi
else
KEEP_WATCHOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_WATCHOS_VERSIONS_CSV" "$(normalize_version "$WATCHOS_MIN_VERSION")")
KEEP_WATCHOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_WATCHOS_VERSIONS_CSV" "$(normalize_version "$WATCHOS_MAX_VERSION")")
fi
}
validate_preferred_models() {
if [[ -z "$PREFERRED_MODELS_CSV" ]]; then
return
fi
if ! xcrun simctl list devicetypes --json >/dev/null 2>&1; then
log_warn "Could not validate --perferred-models because simctl device types are unavailable"
return
fi
local model info
IFS=',' read -r -a models <<< "$PREFERRED_MODELS_CSV"
for model in "${models[@]}"; do
info=$(resolve_preferred_model_devicetype "$model")
if [[ -z "$info" ]]; then
echo "Error: invalid model in --perferred-models: ${model}" >&2
echo "Run with --help to see valid options on this machine." >&2
exit 1
fi
done
}
warn_missing_requested_versions() {
local requested_csv="$1"
local platform="$2"
local available_csv requested
[[ -z "$requested_csv" ]] && return
available_csv=$(available_runtime_versions_csv "$platform")
IFS=',' read -r -a requested_versions <<< "$requested_csv"
for requested in "${requested_versions[@]}"; do
csv_contains_version "$available_csv" "$requested" && continue
if [[ "$platform" == "ios" ]]; then
log_warn "Requested iOS runtime ${requested} is not currently installed"
else
log_warn "Requested watchOS runtime ${requested} is not currently installed"
fi
done
}
extract_prefixed_version() {
local input="$1"
local prefix="$2"
local version
version=$(echo "$input" | sed -nE "s/.*${prefix}[[:space:]_.-]*([0-9]+([._-][0-9]+){0,2}).*/\\1/p" | head -1)
echo "${version//[-_]/.}"
}
extract_leading_version() {
local input="$1"
local version
version=$(echo "$input" | sed -nE 's/^([0-9]+([.][0-9]+){0,2}).*/\1/p' | head -1)
echo "$version"
}
should_keep_named_version() {
local name="$1"
local parsed platform version
parsed=$(parse_named_platform_version "$name") || return 1
platform="${parsed%%|*}"
version="${parsed##*|}"
should_keep_platform_version "$platform" "$version"
}
parse_named_platform_version() {
local name="$1"
local platform version
if [[ "$name" == *"iOS"* ]]; then
platform="ios"
version=$(extract_prefixed_version "$name" "iOS")
elif [[ "$name" == *"watchOS"* ]]; then
platform="watchos"
version=$(extract_prefixed_version "$name" "watchOS")
else
return 1
fi
version=$(normalize_version "$version")
[[ -n "$version" ]] || return 1
echo "${platform}|${version}"
}
should_keep_runtime_key() {
local runtime_key="$1"
local platform version
if [[ "$runtime_key" == *"iOS-"* ]]; then
platform="ios"
version=$(extract_prefixed_version "$runtime_key" "iOS")
elif [[ "$runtime_key" == *"watchOS-"* ]]; then
platform="watchos"
version=$(extract_prefixed_version "$runtime_key" "watchOS")
else
return 1
fi
should_keep_platform_version "$platform" "$version"
}
should_keep_folder_version() {
local platform="$1"
local folder_name="$2"
local folder_version
folder_version=$(extract_leading_version "$folder_name")
[[ -n "$folder_version" ]] || return 1
should_keep_platform_version "$platform" "$folder_version" && return 0
if $NUKE_EVERYTHING; then
return 1
fi
local keep_csv entry
if [[ "$platform" == "ios" ]]; then
keep_csv="$KEEP_IOS_VERSIONS_CSV"
else
keep_csv="$KEEP_WATCHOS_VERSIONS_CSV"
fi
[[ -z "$keep_csv" ]] && return 1
IFS=',' read -r -a entries <<< "$keep_csv"
for entry in "${entries[@]}"; do
versions_match_minor "$folder_version" "$entry" && return 0
done
return 1
}
# Clean a DeviceSupport directory, keeping only the latest patch version
# for each kept major.minor. E.g. if 26.4 and 26.4.1 both exist and 26.4
# is in the keep list, only 26.4.1 is kept.
clean_device_support_dir() {
local platform="$1"
local ds_dir="$2"
local label_prefix="$3"
declare -A latest_per_minor # minor -> "version|path"
# First pass: find the latest patch version per kept minor
for entry in "$ds_dir"/*/; do
[[ -d "$entry" ]] || continue
local folder_name folder_version minor
folder_name=$(basename "$entry")
folder_version=$(extract_leading_version "$folder_name")
[[ -n "$folder_version" ]] || continue
if ! should_keep_folder_version "$platform" "$folder_name"; then
continue
fi
minor=$(normalize_version "$folder_version" | cut -d. -f1-2)
local existing="${latest_per_minor[$minor]:-}"
if [[ -z "$existing" ]]; then
latest_per_minor[$minor]="$folder_version|$entry"
else
local existing_version="${existing%%|*}"
if version_gt "$folder_version" "$existing_version"; then
latest_per_minor[$minor]="$folder_version|$entry"
fi
fi
done
# Second pass: keep only latest patch per minor, remove everything else
for entry in "$ds_dir"/*/; do
[[ -d "$entry" ]] || continue
local folder_name folder_version minor
folder_name=$(basename "$entry")
folder_version=$(extract_leading_version "$folder_name")
if [[ -z "$folder_version" ]]; then
if $NUKE_EVERYTHING; then
remove_path "$entry" "${label_prefix}: ${folder_name}"
else
$VERBOSE && log_skip "Keeping ${label_prefix} (no version): ${folder_name}"
fi
continue
fi
minor=$(normalize_version "$folder_version" | cut -d. -f1-2)
local best="${latest_per_minor[$minor]:-}"
if [[ -z "$best" ]]; then
# This minor is not in the keep list
remove_path "$entry" "${label_prefix}: ${folder_name}"
else
local best_path="${best#*|}"
if [[ "$entry" == "$best_path" ]]; then
log_skip "Keeping ${label_prefix}: ${folder_name}"
else
remove_path "$entry" "${label_prefix}: ${folder_name} (superseded)"
fi
fi
done
}
is_system_image_metadata_entry() {
case "$1" in
Inbox|bundle|mnt|images.plist|.DS_Store)
return 0
;;
*)
return 1
;;
esac
}
# --- Auto-detection ----------------------------------------------------------
# Find project root (look for .xcodeproj in repo root or current dir)
find_pbxproj() {
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local repo_root="${script_dir}/.."
# Try repo root first
for proj in "$repo_root"/*.xcodeproj/project.pbxproj; do
if [[ -f "$proj" ]]; then
echo "$proj"
return
fi
done
# Try current directory
for proj in ./*.xcodeproj/project.pbxproj; do
if [[ -f "$proj" ]]; then
echo "$proj"
return
fi
done
echo ""
}
detect_deployment_targets() {
local pbxproj
pbxproj=$(find_pbxproj)
if [[ -z "$pbxproj" ]]; then
log_warn "Could not find .xcodeproj - using defaults for deployment targets"
IOS_MIN_VERSION="${OVERRIDE_IOS_MIN:-18.0}"
WATCHOS_MIN_VERSION="${OVERRIDE_WATCHOS_MIN:-11.0}"
return
fi
if [[ -z "$OVERRIDE_IOS_MIN" ]]; then
local ios_target
ios_target=$(grep 'IPHONEOS_DEPLOYMENT_TARGET' "$pbxproj" 2>/dev/null \
| sed -nE 's/.*= *([0-9]+([.][0-9]+){0,2}).*/\1/p' \
| sort -V | head -1)
IOS_MIN_VERSION="${ios_target:-18.0}"
else
IOS_MIN_VERSION="$OVERRIDE_IOS_MIN"
fi
if [[ -z "$OVERRIDE_WATCHOS_MIN" ]]; then
local watchos_target
watchos_target=$(grep 'WATCHOS_DEPLOYMENT_TARGET' "$pbxproj" 2>/dev/null \
| sed -nE 's/.*= *([0-9]+([.][0-9]+){0,2}).*/\1/p' \
| sort -V | head -1)
WATCHOS_MIN_VERSION="${watchos_target:-11.0}"
else
WATCHOS_MIN_VERSION="$OVERRIDE_WATCHOS_MIN"
fi
}
detect_latest_sdk() {
if [[ -z "$OVERRIDE_IOS_MAX" ]]; then
local ios_sdk
ios_sdk=$(xcodebuild -showsdks 2>/dev/null \
| grep -o 'iphoneos[0-9]*\.[0-9]*' \
| sed 's/iphoneos//' \
| sort -t. -k1,1n -k2,2n | tail -1)
if [[ -n "$ios_sdk" ]]; then
IOS_MAX_VERSION="$ios_sdk"
else
IOS_MAX_VERSION="${IOS_MIN_VERSION}"
log_warn "Could not detect iOS SDK version from Xcode; defaulting to deployment target (${IOS_MIN_VERSION})"
fi
else
IOS_MAX_VERSION="$OVERRIDE_IOS_MAX"
fi
if [[ -z "$OVERRIDE_WATCHOS_MAX" ]]; then
local watchos_sdk
watchos_sdk=$(xcodebuild -showsdks 2>/dev/null \
| grep -o 'watchos[0-9]*\.[0-9]*' \
| sed 's/watchos//' \
| sort -t. -k1,1n -k2,2n | tail -1)
if [[ -n "$watchos_sdk" ]]; then
WATCHOS_MAX_VERSION="$watchos_sdk"
else
WATCHOS_MAX_VERSION="${WATCHOS_MIN_VERSION}"
log_warn "Could not detect watchOS SDK version from Xcode; defaulting to deployment target (${WATCHOS_MIN_VERSION})"
fi
else
WATCHOS_MAX_VERSION="$OVERRIDE_WATCHOS_MAX"
fi
}
# --- Info mode: show space usage per category --------------------------------
show_info() {
local ios_keep_display watchos_keep_display
ios_keep_display=$(format_csv_for_display "$KEEP_IOS_VERSIONS_CSV")
watchos_keep_display=$(format_csv_for_display "$KEEP_WATCHOS_VERSIONS_CSV")
echo -e "${BOLD}Blinq iOS Developer Environment - Space Usage Report${NC}"
if $NUKE_EVERYTHING; then
echo -e "${RED}${BOLD}*** EVERYTHING MODE - nothing will be kept ***${NC}"
else
echo -e "Detected versions to keep: iOS ${ios_keep_display} | watchOS ${watchos_keep_display}"
if [[ -n "$PREFERRED_MODELS_CSV" ]]; then
echo -e "Preferred models: $(format_csv_for_display "$PREFERRED_MODELS_CSV")"
fi
if [[ -n "$EXCLUDED_PHASES_CSV" ]]; then
echo -e "Excluded phases (cleanup mode): $(format_csv_for_display "$EXCLUDED_PHASES_CSV")"
fi
fi
echo ""
local grand_total=0
local category_bytes=0
# Helper: print a category with size
info_category() {
local label="$1"
local path="$2"
local action="${3:-Would be removed}"
local use_sudo="${4:-false}"
if [[ -e "$path" ]]; then
local size
if $use_sudo; then
size=$(dir_size_human_sudo "$path")
else
size=$(dir_size_human "$path")
fi
if $use_sudo; then
category_bytes=$(dir_size_bytes_sudo "$path")
else
category_bytes=$(dir_size_bytes "$path")
fi
printf " ${BOLD}%-45s${NC} %8s ${DIM}(%s)${NC}\n" "$label" "$size" "$action"
else
category_bytes=0
printf " ${DIM}%-45s${NC} %8s\n" "$label" "N/A"
fi
}
# --- Simulator Runtimes ---
echo -e "${BOLD}--- Simulator Runtimes (system-level) ---${NC}"
RUNTIMES_DIR="/Library/Developer/CoreSimulator/Profiles/Runtimes"
if [[ -d "$RUNTIMES_DIR" ]]; then
for runtime_bundle in "$RUNTIMES_DIR"/*.simruntime; do
[[ -e "$runtime_bundle" ]] || continue
local bundle_name
bundle_name=$(basename "$runtime_bundle")
local keep=false action="Would be REMOVED"
should_keep_named_version "$bundle_name" && keep=true
if $keep; then
action="KEEPING"
fi
info_category "$bundle_name" "$runtime_bundle" "$action" true
if ! $keep; then
grand_total=$((grand_total + category_bytes))
fi
done
else
echo -e " ${DIM}Runtime profiles directory not found${NC}"
fi
IMAGES_DIR="/Library/Developer/CoreSimulator/Images"
if [[ -d "$IMAGES_DIR" ]]; then
for img in "$IMAGES_DIR"/*; do
[[ -e "$img" ]] || continue
local img_name
img_name=$(basename "$img")
if is_system_image_metadata_entry "$img_name"; then
continue
fi
local keep=false action="Would be REMOVED"
should_keep_named_version "$img_name" && keep=true
if $keep; then
action="KEEPING"
fi
info_category "$img_name" "$img" "$action" true
if ! $keep; then
grand_total=$((grand_total + category_bytes))
fi
done
fi
CRYPTEX_DIR="/Library/Developer/CoreSimulator/Cryptex/Images"
if [[ -d "$CRYPTEX_DIR" ]]; then
for img in "$CRYPTEX_DIR"/*; do
[[ -e "$img" ]] || continue
local img_name
img_name=$(basename "$img")
if is_system_image_metadata_entry "$img_name"; then
continue
fi
local keep=false action="Would be REMOVED"
if should_keep_named_version "$img_name"; then
keep=true
elif ! parse_named_platform_version "$img_name" >/dev/null && ! $NUKE_EVERYTHING; then
keep=true
fi
if $keep; then
action="KEEPING"
fi
info_category "$img_name" "$img" "$action" true
if ! $keep; then
grand_total=$((grand_total + category_bytes))
fi
done
fi
# --- User-level directories ---
echo ""
echo -e "${BOLD}--- User-level Developer Directories ---${NC}"
local paths_and_labels=(
"$HOME/Library/Developer/Xcode/DerivedData|DerivedData (build artifacts)|Would be removed"
"$HOME/Library/Developer/Xcode/Archives|Xcode Archives|Would be removed"
"$HOME/Library/Developer/Xcode/iOS Device Logs|iOS Device Logs|Would be removed"
"$HOME/Library/Developer/Xcode/UserData/Previews|SwiftUI Preview Data|Would be removed"
"$HOME/Library/Developer/XCPGDevices|Playground Device Data|Would be removed"
"$HOME/Library/Developer/XCTestDevices|XCTestDevices Data|Would be removed"
"$HOME/Library/Developer/CoreSimulator/Caches|CoreSimulator Caches|Would be removed"
"$HOME/Library/Logs/CoreSimulator|CoreSimulator Logs|Would be removed"
"$HOME/Library/Caches/com.apple.dt.Xcode|Xcode Caches|Would be removed"
"$HOME/Library/Caches/org.swift.swiftpm|SPM Cache|Would be removed"
"$HOME/Library/org.swift.swiftpm|SPM Library Data|Would be removed"
)
for entry in "${paths_and_labels[@]}"; do
IFS='|' read -r path label action <<< "$entry"
info_category "$label" "$path" "$action"
grand_total=$((grand_total + category_bytes))
done
# --- DeviceSupport with version breakdown ---
# Info-mode helper: mirrors clean_device_support_dir logic
info_device_support() {
local platform="$1"
local ds_dir="$2"
local label_prefix="$3"
if [[ ! -d "$ds_dir" ]]; then
echo -e " ${DIM}${label_prefix} not found${NC}"
return
fi
declare -A info_latest_per_minor
for entry_dir in "$ds_dir"/*/; do
[[ -d "$entry_dir" ]] || continue
local folder_name folder_version minor
folder_name=$(basename "$entry_dir")
folder_version=$(extract_leading_version "$folder_name")
[[ -n "$folder_version" ]] || continue
if ! should_keep_folder_version "$platform" "$folder_name"; then
continue
fi
minor=$(normalize_version "$folder_version" | cut -d. -f1-2)
local existing="${info_latest_per_minor[$minor]:-}"
if [[ -z "$existing" ]]; then
info_latest_per_minor[$minor]="$folder_version|$entry_dir"
else
local existing_version="${existing%%|*}"
if version_gt "$folder_version" "$existing_version"; then
info_latest_per_minor[$minor]="$folder_version|$entry_dir"
fi
fi
done
for entry_dir in "$ds_dir"/*/; do
[[ -d "$entry_dir" ]] || continue
local folder_name folder_version minor action
folder_name=$(basename "$entry_dir")
folder_version=$(extract_leading_version "$folder_name")
if [[ -z "$folder_version" ]]; then
if $NUKE_EVERYTHING; then
action="Would be REMOVED"
else
action="KEEPING"
fi
info_category "${label_prefix} $folder_name" "$entry_dir" "$action"
if [[ "$action" == "Would be REMOVED" ]]; then
grand_total=$((grand_total + category_bytes))
fi
continue
fi
minor=$(normalize_version "$folder_version" | cut -d. -f1-2)
local best="${info_latest_per_minor[$minor]:-}"
if [[ -z "$best" ]]; then
action="Would be REMOVED"
else
local best_path="${best#*|}"
if [[ "$entry_dir" == "$best_path" ]]; then
action="KEEPING"
else
action="Would be REMOVED (superseded)"
fi
fi
info_category "${label_prefix} $folder_name" "$entry_dir" "$action"
if [[ "$action" != "KEEPING" ]]; then
grand_total=$((grand_total + category_bytes))
fi
done
}
echo ""
echo -e "${BOLD}--- iOS DeviceSupport ---${NC}"
info_device_support "ios" "$HOME/Library/Developer/Xcode/iOS DeviceSupport" "iOS"
echo ""
echo -e "${BOLD}--- watchOS DeviceSupport ---${NC}"
info_device_support "watchos" "$HOME/Library/Developer/Xcode/watchOS DeviceSupport" "watchOS"
# --- Summary ---
echo ""
echo -e "${BOLD}--- Summary ---${NC}"
echo -e " Total reclaimable space: ${RED}${BOLD}$(bytes_to_human "$grand_total")${NC}"
echo ""
echo -e " ${DIM}Run with --execute to reclaim this space, or without flags for a dry run.${NC}"
}
# --- Pre-flight checks -------------------------------------------------------
preflight() {
if ! command -v xcrun &>/dev/null; then
echo "Error: xcrun not found. Is Xcode installed?" >&2
exit 1
fi
if ! command -v xcodebuild &>/dev/null; then
echo "Error: xcodebuild not found. Is Xcode installed?" >&2
exit 1
fi
}
# --- Parse arguments ---------------------------------------------------------
while [[ $# -gt 0 ]]; do
arg="$1"
case "$arg" in
--execute) DRY_RUN=false ;;
--everything) NUKE_EVERYTHING=true ;;
--interactive) INTERACTIVE=true ;;
--perferred-models=*|--preferred-models=*)
PREFERRED_MODELS_CSV="${arg#*=}"
PREFERRED_MODELS_EXPLICIT=true ;;
--verbose) VERBOSE=true ;;
--info) INFO_ONLY=true ;;
--ios-min=*) OVERRIDE_IOS_MIN="${arg#*=}" ;;
--ios-max=*) OVERRIDE_IOS_MAX="${arg#*=}" ;;
--ios-versions=*) OVERRIDE_IOS_VERSIONS="${arg#*=}" ;;
--watchos-min=*) OVERRIDE_WATCHOS_MIN="${arg#*=}" ;;
--watchos-max=*) OVERRIDE_WATCHOS_MAX="${arg#*=}" ;;
--watchos-versions=*) OVERRIDE_WATCHOS_VERSIONS="${arg#*=}" ;;
--excluded=*) EXCLUDED_PHASES_CSV="${arg#*=}" ;;
--excluded)
shift
if [[ $# -eq 0 ]]; then
echo "Error: --excluded requires a comma-separated value" >&2
exit 1
fi
EXCLUDED_PHASES_CSV="$1"
;;
--help|-h) usage ;;
*) echo "Unknown option: $arg" >&2; usage 1 ;;
esac
shift
done
# --- Main --------------------------------------------------------------------
preflight
if $INTERACTIVE && ! [[ -t 0 ]]; then
log_warn "--interactive requires an interactive terminal; continuing with automatic selection"
INTERACTIVE=false
fi
# Auto-detect versions
detect_deployment_targets
detect_latest_sdk
configure_keep_versions
if [[ -n "$PREFERRED_MODELS_CSV" ]]; then
PREFERRED_MODELS_CSV=$(parse_models_csv "$PREFERRED_MODELS_CSV")
fi
if [[ -n "$EXCLUDED_PHASES_CSV" ]]; then
EXCLUDED_PHASES_CSV=$(parse_excluded_phases_csv "$EXCLUDED_PHASES_CSV")
fi
validate_excluded_phases
if ! $PREFERRED_MODELS_EXPLICIT; then
default_models_csv=$(detect_default_preferred_models)
if [[ -n "$default_models_csv" ]]; then
PREFERRED_MODELS_CSV=$(parse_models_csv "$default_models_csv")
else
log_warn "Could not auto-detect default flagship iPhone/Apple Watch models; keeping all iPhone/Apple Watch models"
fi
fi
validate_preferred_models
if [[ -n "$OVERRIDE_IOS_VERSIONS" ]]; then
warn_missing_requested_versions "$KEEP_IOS_VERSIONS_CSV" "ios"
fi
if [[ -n "$OVERRIDE_WATCHOS_VERSIONS" ]]; then
warn_missing_requested_versions "$KEEP_WATCHOS_VERSIONS_CSV" "watchos"
fi
# --- Info mode ---------------------------------------------------------------
if $INFO_ONLY; then
show_info
exit 0
fi
# --- Normal cleanup mode -----------------------------------------------------
echo -e "${BOLD}Blinq iOS Simulator & SDK Cleanup${NC}"
if $NUKE_EVERYTHING; then
echo -e "${RED}${BOLD}*** EVERYTHING MODE ***${NC}"
echo -e "${RED}ALL simulators, runtimes, SDKs, device support, caches, and build${NC}"
echo -e "${RED}artifacts will be removed. Nothing will be preserved.${NC}"
echo -e "${RED}You will need to re-download simulator runtimes from Xcode afterward.${NC}"
else
ios_keep_display=$(format_csv_for_display "$KEEP_IOS_VERSIONS_CSV")
watchos_keep_display=$(format_csv_for_display "$KEEP_WATCHOS_VERSIONS_CSV")
echo -e "Keeping: iOS ${ios_keep_display} | watchOS ${watchos_keep_display}"
if [[ -n "$PREFERRED_MODELS_CSV" ]]; then
echo -e "Preferred models: $(format_csv_for_display "$PREFERRED_MODELS_CSV")"
fi
if [[ -n "$EXCLUDED_PHASES_CSV" ]]; then
echo -e "Excluded phases: $(format_csv_for_display "$EXCLUDED_PHASES_CSV")"
fi
echo -e "${DIM}(all other runtime versions will be removed)${NC}"
fi
if $DRY_RUN; then
echo -e "${YELLOW}MODE: DRY RUN (pass --execute to actually delete)${NC}"
else
if $NUKE_EVERYTHING; then
echo ""
echo -e "${RED}${BOLD}WARNING: This will delete ALL Xcode simulators, runtimes, and caches.${NC}"
echo -e "${RED}You will need to re-download runtimes and recreate simulators afterward.${NC}"
echo ""
read -r -p "Type 'nuke' to confirm complete removal: " confirm
if [[ "$confirm" != "nuke" ]]; then
echo "Aborted. (You must type 'nuke' to confirm --everything mode.)"
exit 0
fi
else
echo -e "${RED}MODE: EXECUTE (files will be permanently deleted)${NC}"
echo ""
read -r -p "Are you sure? (y/N) " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
fi
fi
# ---- 1. Shutdown all running simulators ------------------------------------
if begin_phase "shutdown-simulators" "Shutting down running simulators"; then
run_cmd "Shutting down all simulators" xcrun simctl shutdown all
fi
# ---- 2. Delete unavailable simulators (ones whose runtime is gone) ---------
if begin_phase "delete-unavailable-simulators" "Removing unavailable simulator devices"; then
run_cmd "Deleting unavailable simulators" xcrun simctl delete unavailable
fi
# ---- 3. Clean up extra simulator sets (playgrounds, previews, IB, testing) --
if begin_phase "auxiliary-simulators" "Cleaning auxiliary simulator sets"; then
for simset in playgrounds previews interfacebuilder testing; do
run_cmd "Cleaning $simset simulators" xcrun simctl --set "$simset" delete all
done
fi
# ---- 4. Remove old simulator runtimes (keep only min + max) ----------------
if begin_phase "runtime-cleanup" "Removing simulator runtimes outside kept versions"; then
# Use simctl runtime list if available (Xcode 15+)
if xcrun simctl runtime list &>/dev/null 2>&1; then
while IFS= read -r line; do
if [[ "$line" =~ ^[[:space:]]*(iOS|watchOS|tvOS|visionOS|xrOS)[[:space:]]+([0-9]+\.[0-9]+) ]]; then
platform="${BASH_REMATCH[1]}"
version="${BASH_REMATCH[2]}"
keep=false
if [[ "$platform" == "iOS" ]]; then
should_keep_platform_version "ios" "$version" && keep=true
elif [[ "$platform" == "watchOS" ]]; then
should_keep_platform_version "watchos" "$version" && keep=true
fi
if $INTERACTIVE; then
should_delete=true
if $keep; then
should_delete=false
fi
if confirm_runtime_delete "$platform" "$version" "$should_delete"; then
keep=false
else
keep=true
fi
fi
if $keep; then
log_skip "Keeping runtime: ${platform} ${version}"
continue
fi
if [[ "$line" =~ ([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}) ]]; then
runtime_id="${BASH_REMATCH[1]}"
run_cmd "Deleting runtime ${platform} ${version}" xcrun simctl runtime delete "$runtime_id"
fi
fi
done < <(xcrun simctl runtime list 2>/dev/null)
else
log_warn "xcrun simctl runtime list not available; skipping runtime cleanup via simctl"
fi
RUNTIMES_DIR="/Library/Developer/CoreSimulator/Profiles/Runtimes"
if [[ -d "$RUNTIMES_DIR" ]]; then
log_head "Cleaning system-level runtime bundles in ${RUNTIMES_DIR}"
for runtime_bundle in "$RUNTIMES_DIR"/*.simruntime; do
[[ -e "$runtime_bundle" ]] || continue
bundle_name=$(basename "$runtime_bundle")
keep=false
should_keep_named_version "$bundle_name" && keep=true
if $keep; then
log_skip "Keeping runtime bundle: ${bundle_name}"
else
unmount_image "$runtime_bundle"
sudo_remove_path "$runtime_bundle" "Runtime bundle: ${bundle_name}"
fi
done
fi
IMAGES_DIR="/Library/Developer/CoreSimulator/Images"
if [[ -d "$IMAGES_DIR" ]]; then
log_head "Cleaning simulator disk images in ${IMAGES_DIR}"
for img in "$IMAGES_DIR"/*; do
[[ -e "$img" ]] || continue
img_name=$(basename "$img")
if is_system_image_metadata_entry "$img_name"; then
$VERBOSE && log_skip "Skipping simulator image metadata entry: ${img_name}"
continue
fi
keep=false
should_keep_named_version "$img_name" && keep=true
if $keep; then
log_skip "Keeping image: ${img_name}"
else
unmount_image "$img"
sudo_remove_path "$img" "Simulator image: ${img_name}"
fi
done
fi
CRYPTEX_DIR="/Library/Developer/CoreSimulator/Cryptex/Images"
if [[ -d "$CRYPTEX_DIR" ]]; then
log_head "Cleaning cryptex runtime images in ${CRYPTEX_DIR}"
for img in "$CRYPTEX_DIR"/*; do
[[ -e "$img" ]] || continue
img_name=$(basename "$img")
if is_system_image_metadata_entry "$img_name"; then
$VERBOSE && log_skip "Skipping cryptex metadata entry: ${img_name}"
continue
fi
keep=false
if should_keep_named_version "$img_name"; then
keep=true
elif ! parse_named_platform_version "$img_name" >/dev/null && ! $NUKE_EVERYTHING; then
keep=true
log_warn "Could not determine version for: ${img_name} (keeping)"
fi
if $keep; then
log_skip "Keeping cryptex image: ${img_name}"
else
unmount_image "$img"
sudo_remove_path "$img" "Cryptex image: ${img_name}"
fi
done
fi
fi
# ---- 5. Remove old simulator devices that use unwanted runtimes ------------
if begin_phase "non-kept-runtime-devices" "Removing simulator devices on non-kept runtimes"; then
if xcrun simctl list devices --json &>/dev/null; then
while IFS= read -r runtime_key; do
keep=false
should_keep_runtime_key "$runtime_key" && keep=true
if ! $keep; then
while IFS= read -r udid; do
[[ -z "$udid" ]] && continue
device_name=$(xcrun simctl list devices --json 2>/dev/null | \
python3 -c "
import sys, json
runtime_key, target_udid = sys.argv[1], sys.argv[2]
data = json.load(sys.stdin)
for dev in data.get('devices', {}).get(runtime_key, []):
if dev['udid'] == target_udid:
print(dev.get('name', 'Unknown'))
break
" "$runtime_key" "$udid" 2>/dev/null || echo "Unknown")
run_cmd "Deleting device $device_name ($udid)" xcrun simctl delete "$udid"
done < <(xcrun simctl list devices --json 2>/dev/null | \
python3 -c "
import sys, json
runtime_key = sys.argv[1]
data = json.load(sys.stdin)
for dev in data.get('devices', {}).get(runtime_key, []):
print(dev['udid'])
" "$runtime_key" 2>/dev/null)
else
$VERBOSE && log_skip "Keeping devices on runtime: ${runtime_key}"
fi
done < <(xcrun simctl list devices --json 2>/dev/null | \
python3 -c "
import sys, json
data = json.load(sys.stdin)
for key in data.get('devices', {}):
print(key)
" 2>/dev/null)
fi
fi
# ---- 6. Trim simulator devices (or to --perferred-models when set) ---------
if begin_phase "device-model-trim" "Trimming simulator devices to preferred models"; then
if $NUKE_EVERYTHING; then
run_cmd "Deleting all simulator devices" xcrun simctl delete all
else
if [[ -n "$PREFERRED_MODELS_CSV" ]]; then
log "Removing simulators not in --perferred-models from kept runtimes"
else
log "Removing non-iPhone/Apple Watch simulators from kept runtimes"
fi
# simctl outputs: " DeviceName (UDID) (State)"
device_re='^[[:space:]]+(.+)[[:space:]]+\(([0-9A-F-]{36})\)[[:space:]]+\(([^)]+)\)'
while IFS= read -r line; do
if [[ "$line" =~ $device_re ]]; then
name=$(trim_whitespace "${BASH_REMATCH[1]}")
udid="${BASH_REMATCH[2]}"
keep_device=false
if [[ -n "$PREFERRED_MODELS_CSV" ]]; then
model_is_preferred "$name" && keep_device=true
elif [[ "$name" == iPhone* ]] || [[ "$name" == Apple\ Watch* ]]; then
keep_device=true
fi
if $keep_device; then
$VERBOSE && log_skip "Keeping simulator: ${name}"
continue
fi
run_cmd "Deleting non-target simulator: ${name}" xcrun simctl delete "$udid"
fi
done < <(xcrun simctl list devices available 2>/dev/null)
if [[ -n "$PREFERRED_MODELS_CSV" ]]; then
log "Ensuring preferred simulator models exist on targeted versions"
IFS=',' read -r -a preferred_models <<< "$PREFERRED_MODELS_CSV"
for model in "${preferred_models[@]}"; do
model_info=$(resolve_preferred_model_devicetype "$model")
[[ -z "$model_info" ]] && continue
devicetype_id="${model_info%%|*}"
model_platform="${model_info##*|}"
if model_has_device_on_kept_versions "$model" "$model_platform"; then
$VERBOSE && log_skip "Simulator already exists on kept versions: ${model}"
continue
fi
target_version=$(highest_targeted_version "$model_platform")
target_runtime_id=$(runtime_id_for_platform_version "$model_platform" "$target_version")
if [[ -z "$target_runtime_id" ]]; then
log_warn "No installed ${model_platform} runtime matches target ${target_version} for ${model}"
continue
fi
run_cmd "Creating simulator ${model} (${target_version})" \
xcrun simctl create "$model" "$devicetype_id" "$target_runtime_id"
done
fi
fi
fi
# ---- 7. iOS DeviceSupport (physical device debug symbols) ------------------
if begin_phase "ios-device-support" "Cleaning ~/Library/Developer/Xcode/iOS DeviceSupport"; then
IOS_DS_DIR="$HOME/Library/Developer/Xcode/iOS DeviceSupport"
if [[ -d "$IOS_DS_DIR" ]]; then
clean_device_support_dir "ios" "$IOS_DS_DIR" "iOS DeviceSupport"
else
log "Directory not found: ${IOS_DS_DIR}"
fi
fi
# ---- 8. watchOS DeviceSupport ---------------------------------------------
if begin_phase "watchos-device-support" "Cleaning ~/Library/Developer/Xcode/watchOS DeviceSupport"; then
WATCHOS_DS_DIR="$HOME/Library/Developer/Xcode/watchOS DeviceSupport"
if [[ -d "$WATCHOS_DS_DIR" ]]; then
clean_device_support_dir "watchos" "$WATCHOS_DS_DIR" "watchOS DeviceSupport"
else
log "Directory not found: ${WATCHOS_DS_DIR}"
fi
fi
# ---- 9. DerivedData -------------------------------------------------------
if begin_phase "derived-data" "Cleaning ~/Library/Developer/Xcode/DerivedData"; then
DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData"
if [[ -d "$DERIVED_DATA" ]]; then
for entry in "$DERIVED_DATA"/*/; do
[[ -d "$entry" ]] || continue
folder_name=$(basename "$entry")
if [[ "$folder_name" == "ModuleCache.noindex" ]]; then
remove_path "$entry" "DerivedData/ModuleCache.noindex"
else
remove_path "$entry" "DerivedData: ${folder_name}"
fi
done
else
log "Directory not found: ${DERIVED_DATA}"
fi
fi
# ---- 10. Archives ----------------------------------------------------------
if begin_phase "archives" "Cleaning ~/Library/Developer/Xcode/Archives"; then
ARCHIVES_DIR="$HOME/Library/Developer/Xcode/Archives"
if [[ -d "$ARCHIVES_DIR" ]]; then
remove_path "$ARCHIVES_DIR" "Xcode Archives"
if ! $DRY_RUN; then
mkdir -p "$ARCHIVES_DIR"
fi
else
log "Directory not found: ${ARCHIVES_DIR}"
fi
fi
# ---- 11. iOS Device Logs ---------------------------------------------------
if begin_phase "ios-device-logs" "Cleaning ~/Library/Developer/Xcode/iOS Device Logs"; then
IOS_LOGS="$HOME/Library/Developer/Xcode/iOS Device Logs"
if [[ -d "$IOS_LOGS" ]]; then
remove_path "$IOS_LOGS" "iOS Device Logs"
else
log "Directory not found: ${IOS_LOGS}"
fi
fi
# ---- 12. CoreSimulator caches and logs -------------------------------------
if begin_phase "core-simulator-cache-logs" "Cleaning CoreSimulator caches and logs"; then
CS_CACHES="$HOME/Library/Developer/CoreSimulator/Caches"
if [[ -d "$CS_CACHES" ]]; then
remove_path "$CS_CACHES" "CoreSimulator/Caches"
fi
CS_LOGS="$HOME/Library/Logs/CoreSimulator"
if [[ -d "$CS_LOGS" ]]; then
remove_path "$CS_LOGS" "Logs/CoreSimulator"
fi
fi
# ---- 13. XCTestDevices dead data -------------------------------------------
if begin_phase "xctestdevices-dead" "Cleaning XCTestDevices data"; then
XCTEST_DIR="$HOME/Library/Developer/XCTestDevices"
if [[ -d "$XCTEST_DIR" ]]; then
for dev_dir in "$XCTEST_DIR"/*/; do
[[ -d "$dev_dir" ]] || continue
remove_path "$dev_dir" "XCTestDevices/$(basename "$dev_dir")"
done
else
log "Directory not found: ${XCTEST_DIR}"
fi
fi
# ---- 14. Xcode caches ------------------------------------------------------
if begin_phase "xcode-caches" "Cleaning Xcode caches"; then
XCODE_CACHE="$HOME/Library/Caches/com.apple.dt.Xcode"
if [[ -d "$XCODE_CACHE" ]]; then
remove_path "$XCODE_CACHE" "Caches/com.apple.dt.Xcode"
fi
fi
# ---- 15. Playground device data --------------------------------------------
if begin_phase "playground-data" "Cleaning Xcode Playground data"; then
XCPG_DEVICES="$HOME/Library/Developer/XCPGDevices"
if [[ -d "$XCPG_DEVICES" ]]; then
remove_path "$XCPG_DEVICES" "XCPGDevices (Playground data)"
fi
fi
# ---- 16. Xcode Previews (SwiftUI) -----------------------------------------
if begin_phase "swiftui-preview" "Cleaning SwiftUI Preview simulators"; then
PREVIEWS_DIR="$HOME/Library/Developer/Xcode/UserData/Previews"
if [[ -d "$PREVIEWS_DIR" ]]; then
remove_path "$PREVIEWS_DIR" "UserData/Previews (SwiftUI)"
fi
fi
# ---- 17. SPM caches --------------------------------------------------------
if begin_phase "spm-caches" "Cleaning Swift Package Manager caches"; then
SPM_CACHE="$HOME/Library/Caches/org.swift.swiftpm"
if [[ -d "$SPM_CACHE" ]]; then
remove_path "$SPM_CACHE" "SPM cache"
fi
SPM_LIB="$HOME/Library/org.swift.swiftpm"
if [[ -d "$SPM_LIB" ]]; then
remove_path "$SPM_LIB" "SPM library data"
fi
fi
# ---- 18. Summary -----------------------------------------------------------
log_head "Summary"
total_human=$(bytes_to_human "$TOTAL_FREED")
if $DRY_RUN; then
echo -e "${YELLOW}Estimated space to reclaim: ${BOLD}${total_human}${NC}"
echo -e "${YELLOW}Run with --execute to actually delete files.${NC}"
else
echo -e "${GREEN}Total space reclaimed: ${BOLD}${total_human}${NC}"
fi
# Show what's left
echo ""
log "Remaining simulators:"
xcrun simctl list devices available 2>/dev/null | head -30 || true
echo ""
log "Remaining runtimes:"
xcrun simctl runtime list 2>/dev/null | head -10 || \
xcrun simctl list runtimes 2>/dev/null | head -10 || true
echo ""
echo -e "${GREEN}Done.${NC}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment