Skip to content

Instantly share code, notes, and snippets.

@cescox
Last active March 31, 2026 09:09
Show Gist options
  • Select an option

  • Save cescox/d76447e0677e5deedf9cd725a332334b to your computer and use it in GitHub Desktop.

Select an option

Save cescox/d76447e0677e5deedf9cd725a332334b to your computer and use it in GitHub Desktop.
macOS Time Machine exclusions for developers — Docker, OrbStack, node_modules, Homebrew, Xcode, package caches & more
#!/usr/bin/env zsh
# =============================================================================
# tm-exclude-dev.sh — Complete Time Machine setup for macOS developers
# =============================================================================
#
# What this does:
# 1. Excludes 70+ global cache/VM/tool directories from Time Machine
# 2. Scans for per-project dependency dirs (node_modules, target, .venv, etc.)
# 3. Installs & starts asimeow to auto-exclude new per-project deps every 6h
#
# Usage:
# curl -sL <gist-raw-url> | zsh # Dry run
# curl -sL <gist-raw-url> | zsh -s -- --apply # Apply everything
#
# # Or download and run:
# chmod +x tm-exclude-dev.sh
# ./tm-exclude-dev.sh # Dry run (show what would happen)
# ./tm-exclude-dev.sh --apply # Apply exclusions + install asimeow
#
# Notes:
# - Uses sticky exclusions ($TMUTIL addexclusion without -p), no sudo needed
# - Sticky exclusions follow the directory if moved/renamed
# - ~/Library/Caches is already excluded by macOS by default
# - APFS local snapshots always include everything; exclusions only affect
# what gets copied to the backup destination
# - Safe to re-run — skips already-excluded paths
#
# References:
# - asimeow: https://github.com/mdnmdn/asimeow
# - asimov: https://github.com/stevegrunwell/asimov
# - tmutil: man tmutil
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Ensure standard paths are in PATH
# ---------------------------------------------------------------------------
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:$PATH"
TMUTIL=/usr/bin/tmutil
DU=/usr/bin/du
FIND=/usr/bin/find
# Verify tmutil exists
if [[ ! -x "$TMUTIL" ]]; then
echo "Error: tmutil not found at $TMUTIL" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# ---------------------------------------------------------------------------
# Global directories to exclude
# ---------------------------------------------------------------------------
# Format: "path|description|category"
#
# NOTE: ~/Library/Caches (and subdirs like JetBrains, Homebrew, pip, etc.)
# are already excluded by macOS by default — no need to list them here.
EXCLUSIONS=(
# --- Container Runtimes & VMs ---
"$HOME/Library/Containers/com.docker.docker/Data|Docker Desktop VM disk image|Container Runtimes & VMs"
"$HOME/.docker|Docker CLI config and plugins|Container Runtimes & VMs"
"$HOME/Library/Group Containers/HUAQ24HBR6.dev.orbstack|OrbStack VM and container data|Container Runtimes & VMs"
"$HOME/.colima|Colima VM disk images|Container Runtimes & VMs"
"$HOME/.lima|Lima VM data|Container Runtimes & VMs"
"$HOME/.local/share/containers|Podman container storage|Container Runtimes & VMs"
"$HOME/.vagrant.d|Vagrant global boxes cache|Container Runtimes & VMs"
"$HOME/Library/Parallels|Parallels Desktop VMs|Container Runtimes & VMs"
"$HOME/Documents/Virtual Machines|VMware Fusion VMs|Container Runtimes & VMs"
"$HOME/.VirtualBox|VirtualBox config|Container Runtimes & VMs"
"$HOME/VirtualBox VMs|VirtualBox VM images|Container Runtimes & VMs"
"$HOME/Library/Containers/com.utmapp.UTM/Data/Documents|UTM VM images|Container Runtimes & VMs"
# --- Package Manager Global Caches ---
"$HOME/Library/pnpm|pnpm content-addressable store|Package Caches"
"$HOME/.npm|npm global cache|Package Caches"
"$HOME/.yarn/cache|Yarn cache|Package Caches"
"$HOME/.cache/yarn|Yarn cache (alt location)|Package Caches"
"$HOME/.pnpm-store|pnpm store (alt location)|Package Caches"
"$HOME/.bun|Bun runtime and cache|Package Caches"
"$HOME/.deno|Deno runtime cache|Package Caches"
"$HOME/.cargo|Rust Cargo registry and build cache|Package Caches"
"$HOME/.rustup|Rust toolchain installations|Package Caches"
"$HOME/go|Go modules and build cache|Package Caches"
"$HOME/.gem|Ruby gems|Package Caches"
"$HOME/.rubies|Ruby versions (chruby)|Package Caches"
"$HOME/.composer|PHP Composer packages|Package Caches"
"$HOME/.cocoapods|CocoaPods spec repos|Package Caches"
"$HOME/.pub-cache|Dart/Flutter pub cache|Package Caches"
"$HOME/.nuget|.NET NuGet packages|Package Caches"
"$HOME/.m2|Maven local repository|Package Caches"
"$HOME/.gradle|Gradle global cache|Package Caches"
"$HOME/.swiftpm|Swift Package Manager cache|Package Caches"
"$HOME/.kotlin|Kotlin compiler data|Package Caches"
# --- Version Managers ---
"$HOME/.nvm|Node Version Manager|Version Managers"
"$HOME/.fnm|Fast Node Manager|Version Managers"
"$HOME/.volta|Volta (JS tool manager)|Version Managers"
"$HOME/.asdf|asdf version manager|Version Managers"
"$HOME/.sdkman|SDKMAN (Java SDK manager)|Version Managers"
"$HOME/.jabba|Jabba (Java version manager)|Version Managers"
"$HOME/.pyenv|pyenv (Python versions)|Version Managers"
"$HOME/.rbenv|rbenv (Ruby versions)|Version Managers"
"$HOME/.rvm|RVM (Ruby Version Manager)|Version Managers"
# --- Python Environments ---
"$HOME/.conda|Conda environments|Python"
"$HOME/miniconda3|Miniconda installation|Python"
"$HOME/anaconda3|Anaconda installation|Python"
"$HOME/.local/share/virtualenvs|Pipenv virtualenvs|Python"
# --- Homebrew ---
"/opt/homebrew|Homebrew (Apple Silicon)|Homebrew"
"/usr/local/Cellar|Homebrew (Intel)|Homebrew"
# --- Xcode / Apple Dev ---
"$HOME/Library/Developer/Xcode/DerivedData|Xcode build products and indexes|Xcode"
"$HOME/Library/Developer/Xcode/iOS DeviceSupport|iOS debug symbols per version|Xcode"
"$HOME/Library/Developer/Xcode/watchOS DeviceSupport|watchOS debug symbols|Xcode"
"$HOME/Library/Developer/Xcode/tvOS DeviceSupport|tvOS debug symbols|Xcode"
"$HOME/Library/Developer/Xcode/DocumentationCache|Xcode documentation cache|Xcode"
"$HOME/Library/Developer/CoreSimulator|Simulator runtimes and data|Xcode"
"$HOME/Library/Developer/XCPGDevices|Xcode Playground devices|Xcode"
"$HOME/Library/Developer/XCTestDevices|Xcode test devices|Xcode"
# --- Android ---
"$HOME/.android/avd|Android Virtual Device images|Android"
# --- IDEs & Editors ---
"$HOME/Library/Application Support/JetBrains|JetBrains IDEs data|IDEs & Editors"
"$HOME/.vscode/extensions|VS Code extensions|IDEs & Editors"
"$HOME/Library/Application Support/Code|VS Code app data|IDEs & Editors"
"$HOME/.cursor|Cursor editor data|IDEs & Editors"
"$HOME/Library/Application Support/Cursor|Cursor app data|IDEs & Editors"
"$HOME/.trae|Trae editor data|IDEs & Editors"
# --- AI Tools ---
"$HOME/.claude|Claude Code session data and caches|AI Tools"
"$HOME/Library/Application Support/Claude|Claude Desktop app data|AI Tools"
"$HOME/.gemini|Google Gemini CLI cache|AI Tools"
"$HOME/.codeium|Codeium AI data|AI Tools"
"$HOME/.continue|Continue AI extension data|AI Tools"
# --- General Caches ---
"$HOME/.cache|XDG cache dir (pip, pre-commit, Hugging Face, etc.)|General Caches"
# --- Compiler Caches ---
"$HOME/.ccache|ccache compilation cache|Compiler Caches"
# --- IaC & Cloud ---
"$HOME/.terraform.d|Terraform plugin cache|IaC & Cloud"
"$HOME/Library/Application Support/sst|SST framework cache|IaC & Cloud"
# --- Browsers (profile data, synced elsewhere) ---
"$HOME/Library/Application Support/Google/Chrome|Chrome profile data|Browsers"
"$HOME/Library/Application Support/BraveSoftware|Brave profile data|Browsers"
"$HOME/Library/Application Support/Firefox|Firefox profile data|Browsers"
"$HOME/Library/Application Support/Vivaldi|Vivaldi profile data|Browsers"
# --- Misc ---
"$HOME/.wine|Wine compatibility layer|Misc"
)
# ---------------------------------------------------------------------------
# Per-project dependency patterns (dirname|sentinel file)
# ---------------------------------------------------------------------------
DEP_PATTERNS=(
"node_modules|package.json"
".venv|requirements.txt"
".venv|pyproject.toml"
"venv|requirements.txt"
"venv|pyproject.toml"
"target|Cargo.toml"
"target|pom.xml"
"target|build.sbt"
"vendor|composer.json"
"vendor|Gemfile"
"vendor|go.mod"
"Pods|Podfile"
"_build|mix.exs"
"deps|mix.exs"
".build|Package.swift"
".build|mix.exs"
".terraform|main.tf"
".terragrunt-cache|terragrunt.hcl"
"cdk.out|cdk.json"
".dart_tool|pubspec.yaml"
".packages|pubspec.yaml"
".stack-work|stack.yaml"
".parcel-cache|package.json"
".next|package.json"
".nuxt|package.json"
".turbo|package.json"
"bower_components|bower.json"
".vagrant|Vagrantfile"
".gradle|build.gradle"
".gradle|build.gradle.kts"
".tox|tox.ini"
".nox|noxfile.py"
)
# ---------------------------------------------------------------------------
# Functions
# ---------------------------------------------------------------------------
print_header() {
echo ""
echo "${BOLD}${BLUE}=== $1 ===${NC}"
}
get_size() {
local du_output
du_output=$($DU -sh "$1" 2>/dev/null) || du_output=""
local size="${du_output%% *}"
size="${size## }"
size="${size%% }"
[[ -z "$size" ]] && size="?"
echo "$size"
}
check_and_exclude() {
local path="$1"
local description="$2"
local dry_run="$3"
# Skip if path doesn't exist
if [[ ! -e "$path" ]]; then
return
fi
local size
size=$(get_size "$path")
# Check if already excluded
local excluded
excluded=$($TMUTIL isexcluded "$path" 2>&1) || excluded=""
if [[ "$excluded" == *"[Excluded]"* ]]; then
printf " ${YELLOW}skip${NC} %-8s %s ${YELLOW}(already excluded)${NC}\n" "$size" "$description"
return
fi
if [[ "$dry_run" == "true" ]]; then
printf " ${GREEN}todo${NC} %-8s %s\n" "$size" "$description"
printf " %s\n" "$path"
else
if $TMUTIL addexclusion "$path"; then
printf " ${GREEN}done${NC} %-8s %s\n" "$size" "$description"
else
printf " ${RED}fail${NC} %-8s %s\n" "$size" "$description"
fi
fi
}
install_asimeow() {
local dry_run="$1"
print_header "asimeow (automated per-project exclusions)"
# Check if already installed
if command -v asimeow &>/dev/null; then
echo " ${YELLOW}skip${NC} asimeow is already installed"
# Check if service is running
if brew services list 2>/dev/null | grep -q "asimeow.*started"; then
echo " ${YELLOW}skip${NC} asimeow service is already running"
else
if [[ "$dry_run" == "true" ]]; then
echo " ${GREEN}todo${NC} Would start asimeow service (runs every 6 hours)"
else
echo " ${BLUE}....${NC} Starting asimeow service..."
brew services start asimeow 2>/dev/null && \
echo " ${GREEN}done${NC} asimeow service started (runs every 6 hours)" || \
echo " ${RED}fail${NC} Could not start asimeow service"
fi
fi
return
fi
# Check if asimov is installed (similar tool)
if command -v asimov &>/dev/null; then
echo " ${YELLOW}skip${NC} asimov (similar tool) is already installed"
return
fi
# Check if brew is available
if ! command -v brew &>/dev/null; then
echo " ${RED}fail${NC} Homebrew not found — install it first: https://brew.sh"
return
fi
if [[ "$dry_run" == "true" ]]; then
echo " ${GREEN}todo${NC} Would install asimeow via Homebrew"
echo " ${GREEN}todo${NC} Would start asimeow service (runs every 6 hours)"
echo ""
echo " asimeow auto-excludes per-project dependency dirs:"
echo " node_modules, target/, .venv/, vendor/, Pods/, _build/,"
echo " .dart_tool/, .stack-work/, .terraform/, and more."
echo " Configurable via ~/.config/asimeow/config.yaml"
else
echo " ${BLUE}....${NC} Installing asimeow..."
if brew tap mdnmdn/asimeow 2>/dev/null && brew install asimeow 2>/dev/null; then
echo " ${GREEN}done${NC} asimeow installed"
echo " ${BLUE}....${NC} Running initial scan..."
asimeow 2>/dev/null && \
echo " ${GREEN}done${NC} Initial scan complete" || \
echo " ${YELLOW}warn${NC} Initial scan had issues (non-fatal)"
echo " ${BLUE}....${NC} Starting asimeow service..."
brew services start asimeow 2>/dev/null && \
echo " ${GREEN}done${NC} asimeow service started (runs every 6 hours)" || \
echo " ${RED}fail${NC} Could not start service — run manually: asimeow"
else
echo " ${RED}fail${NC} Could not install asimeow"
echo " Try manually: brew tap mdnmdn/asimeow && brew install asimeow"
fi
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
DRY_RUN="true"
if [[ "${1:-}" == "--apply" ]]; then
DRY_RUN="false"
fi
echo ""
echo "${BOLD}Time Machine Developer Exclusions${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ "$DRY_RUN" == "true" ]]; then
echo "${YELLOW}DRY RUN — showing what would happen. Run with --apply to apply.${NC}"
else
echo "${GREEN}Applying exclusions...${NC}"
fi
# =========================================================================
# Step 1: Global directory exclusions
# =========================================================================
current_category=""
for entry in "${EXCLUSIONS[@]}"; do
IFS='|' read -r path description category <<< "$entry"
if [[ "$category" != "$current_category" ]]; then
print_header "$category"
current_category="$category"
fi
check_and_exclude "$path" "$description" "$DRY_RUN"
done
# =========================================================================
# Step 2: Per-project dependency sweep (one-time)
# =========================================================================
print_header "Per-Project Dependencies (one-time sweep)"
dep_count=0
dep_limit=200
for pattern in "${DEP_PATTERNS[@]}"; do
IFS='|' read -r dirname sentinel <<< "$pattern"
while IFS= read -r dir; do
parent="${dir%/*}"
if [[ -f "$parent/$sentinel" ]]; then
dep_count=$((dep_count + 1))
[[ $dep_count -gt $dep_limit ]] && break 2
check_and_exclude "$dir" "$dirname ($sentinel)" "$DRY_RUN"
fi
done < <($FIND "$HOME" -name "$dirname" -type d -prune \
-not -path "$HOME/Library/*" \
-not -path "$HOME/.Trash/*" \
-not -path "$HOME/.cache/*" \
-not -path "$HOME/.npm/*" \
-not -path "$HOME/.cargo/*" \
-not -path "$HOME/.nvm/*" \
-not -path "$HOME/.bun/*" \
-not -path "$HOME/.deno/*" \
2>/dev/null)
done
if [[ $dep_count -gt $dep_limit ]]; then
echo " ${YELLOW}...and more (capped at $dep_limit)${NC}"
fi
if [[ $dep_count -eq 0 ]]; then
echo " ${YELLOW}none found${NC}"
fi
# =========================================================================
# Step 3: Install asimeow for ongoing coverage
# =========================================================================
install_asimeow "$DRY_RUN"
# =========================================================================
# Summary
# =========================================================================
echo ""
echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
if [[ "$DRY_RUN" == "true" ]]; then
echo "${YELLOW}This was a dry run. To apply everything:${NC}"
echo " ${BOLD}./tm-exclude-dev.sh --apply${NC}"
else
echo "${GREEN}All done!${NC}"
echo ""
echo "Verify any path with: $TMUTIL isexcluded <path>"
echo "List all exclusions: sudo mdfind \"com_apple_backup_excludeItem = 'com.apple.backupd'\""
fi
echo ""
echo "Already excluded by macOS by default:"
echo " ~/Library/Caches (+ all subdirs), ~/Library/Logs,"
echo " .Spotlight-V100, .Trashes, /private/var/vm, System volume"
echo ""
echo "Caution — Browsers: bookmarks & passwords are in the excluded"
echo " profile dirs. Ensure browser sync is enabled before excluding."
echo ""
echo "Safe to re-run anytime — already-excluded paths are skipped."
echo ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment