Created
August 17, 2026 07:11
-
-
Save TimMikeladze/97a01d74edc1dcac25dc941ce85af0d1 to your computer and use it in GitHub Desktop.
Reclaim disk space: a safe Bash script that finds and deletes regenerable build artifacts and caches — node_modules, .next, dist, target, .venv, __pycache__ and more — with size reports by artifact type and by project. Dry run by default, never touches .git. Works on macOS and Linux.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # reclaim-dev-disk-space.sh — recursively find and delete regenerable build, | |
| # cache, and dependency directories (node_modules, .next, dist, target, .venv, | |
| # __pycache__, ...) to reclaim disk space. Dry run by default. | |
| # | |
| # Usage: | |
| # ./reclaim-dev-disk-space.sh [--apply] [--yes] [TARGET_DIR ...] | |
| # | |
| # (no flags) Dry run. Lists what WOULD be deleted, with sizes. Deletes nothing. | |
| # --apply Actually delete. Prompts once for confirmation. | |
| # --yes Skip the confirmation prompt (only meaningful with --apply). | |
| # TARGET_DIR One or more directories to clean. Defaults to the current directory. | |
| # | |
| # Examples: | |
| # ./reclaim-dev-disk-space.sh ~/code # dry run over everything | |
| # ./reclaim-dev-disk-space.sh --apply ~/code/my-app # clean one project | |
| # ./reclaim-dev-disk-space.sh --apply --yes ~/code # no prompt | |
| # | |
| # .git directories are never touched, and are never descended into. | |
| set -euo pipefail | |
| # Directory names considered disposable. Everything here regenerates from a | |
| # build / install step. Add or remove entries to taste. | |
| TARGET_NAMES=( | |
| node_modules | |
| .next | |
| .turbo | |
| .nuxt | |
| .svelte-kit | |
| .astro | |
| .parcel-cache | |
| .vite | |
| .cache | |
| .expo | |
| dist | |
| build | |
| out | |
| target # Rust / Java build output | |
| .gradle | |
| .pytest_cache | |
| .mypy_cache | |
| .ruff_cache | |
| __pycache__ | |
| .venv | |
| venv | |
| coverage | |
| .nyc_output | |
| .playwright-mcp | |
| playwright-report | |
| test-results | |
| .terraform | |
| ) | |
| APPLY=0 | |
| ASSUME_YES=0 | |
| ROOTS=() | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --apply) APPLY=1; shift ;; | |
| --yes|-y) ASSUME_YES=1; shift ;; | |
| -h|--help) sed -n '2,20p' "$0"; exit 0 ;; | |
| -*) echo "Unknown option: $1" >&2; exit 2 ;; | |
| *) ROOTS+=("$1"); shift ;; | |
| esac | |
| done | |
| if [[ ${#ROOTS[@]} -eq 0 ]]; then | |
| ROOTS=(".") | |
| fi | |
| for root in "${ROOTS[@]}"; do | |
| if [[ ! -d "$root" ]]; then | |
| echo "Not a directory: $root" >&2 | |
| exit 1 | |
| fi | |
| done | |
| # Build the find expression: | |
| # -name .git -prune -o \( -name node_modules -o -name .next ... \) -type d -print0 | |
| # | |
| # Pruning .git means we never descend into it and never match anything inside it. | |
| # Pruning each matched target means we don't recurse into a doomed directory | |
| # looking for nested matches (e.g. node_modules inside node_modules). | |
| FIND_ARGS=(-name .git -prune -o "(") | |
| first=1 | |
| for name in "${TARGET_NAMES[@]}"; do | |
| if [[ $first -eq 1 ]]; then | |
| first=0 | |
| else | |
| FIND_ARGS+=(-o) | |
| fi | |
| FIND_ARGS+=(-name "$name") | |
| done | |
| FIND_ARGS+=(")" -type d -prune -print0) | |
| # Collect matches (NUL-delimited, so paths with spaces/newlines are safe). | |
| MATCHES=() | |
| while IFS= read -r -d '' path; do | |
| MATCHES+=("$path") | |
| done < <(find "${ROOTS[@]}" "${FIND_ARGS[@]}" 2>/dev/null) | |
| if [[ ${#MATCHES[@]} -eq 0 ]]; then | |
| echo "Nothing to clean under: ${ROOTS[*]}" | |
| exit 0 | |
| fi | |
| echo "Found ${#MATCHES[@]} directories." | |
| echo | |
| # Measure every match once, in KB, into a temp file: "<kb>\t<path>" per line. | |
| # All three summaries below are computed from this one walk rather than | |
| # re-running du per grouping. | |
| SIZES=$(mktemp -t reclaim-dev-disk-space) | |
| trap 'rm -f "$SIZES"' EXIT | |
| printf '%s\0' "${MATCHES[@]}" | xargs -0 du -sk 2>/dev/null > "$SIZES" | |
| # Reusable human-readable formatter for a KB count. | |
| human() { | |
| awk -v k="$1" 'BEGIN { | |
| split("K M G T P", u, " "); | |
| i = 1; | |
| while (k >= 1024 && i < 5) { k /= 1024; i++ } | |
| printf "%.1f%s", k, u[i] | |
| }' | |
| } | |
| # Longest common prefix of the roots, so per-project grouping below can strip it | |
| # and print short names ("my-app") instead of full paths. | |
| if [[ ${#ROOTS[@]} -eq 1 ]]; then | |
| PREFIX="${ROOTS[0]%/}/" | |
| else | |
| PREFIX="" | |
| fi | |
| echo "=== By artifact type ===" | |
| awk -F'\t' '{ | |
| n = split($2, parts, "/"); | |
| type = parts[n]; | |
| kb[type] += $1; | |
| count[type]++; | |
| } | |
| END { for (t in kb) printf "%d\t%d\t%s\n", kb[t], count[t], t }' "$SIZES" \ | |
| | sort -rn \ | |
| | while IFS=$'\t' read -r kb count type; do | |
| printf ' %8s %4s dirs %s\n' "$(human "$kb")" "$count" "$type" | |
| done | |
| echo | |
| echo "=== By project (top 25) ===" | |
| awk -F'\t' -v prefix="$PREFIX" '{ | |
| path = $2; | |
| if (prefix != "" && index(path, prefix) == 1) { | |
| rest = substr(path, length(prefix) + 1); | |
| } else { | |
| rest = path; | |
| } | |
| n = split(rest, parts, "/"); | |
| project = (n > 1) ? parts[1] : rest; | |
| kb[project] += $1; | |
| count[project]++; | |
| } | |
| END { for (p in kb) printf "%d\t%d\t%s\n", kb[p], count[p], p }' "$SIZES" \ | |
| | sort -rn \ | |
| | head -25 \ | |
| | while IFS=$'\t' read -r kb count project; do | |
| printf ' %8s %4s dirs %s\n' "$(human "$kb")" "$count" "$project" | |
| done | |
| echo | |
| if [[ $APPLY -eq 0 ]]; then | |
| # Dry run: show the complete list, largest first. Nothing is hidden, so what | |
| # you read here is exactly what --apply would remove. | |
| echo "=== Full list of directories that WOULD be deleted (${#MATCHES[@]}) ===" | |
| else | |
| echo "=== Full list of directories to delete (${#MATCHES[@]}) ===" | |
| fi | |
| sort -rn "$SIZES" | while IFS=$'\t' read -r kb path; do | |
| printf ' %8s %s\n' "$(human "$kb")" "$path" | |
| done | |
| # Grand total. Summing the KB column is correct here because matched directories | |
| # are pruned by find, so no match is ever nested inside another match. | |
| TOTAL=$(awk -F'\t' '{ sum += $1 } END { print sum + 0 }' "$SIZES") | |
| TOTAL_HUMAN=$(human "$TOTAL") | |
| echo | |
| echo "────────────────────────────────────────" | |
| printf 'TOTAL: %s across %d directories\n' "$TOTAL_HUMAN" "${#MATCHES[@]}" | |
| echo "────────────────────────────────────────" | |
| echo | |
| if [[ $APPLY -eq 0 ]]; then | |
| echo "DRY RUN — nothing deleted. Re-run with --apply to delete." | |
| exit 0 | |
| fi | |
| if [[ $ASSUME_YES -eq 0 ]]; then | |
| read -r -p "Permanently delete these ${#MATCHES[@]} directories? [y/N] " reply | |
| case "$reply" in | |
| y|Y|yes|YES) ;; | |
| *) echo "Aborted."; exit 1 ;; | |
| esac | |
| fi | |
| printf '%s\0' "${MATCHES[@]}" | xargs -0 rm -rf | |
| echo "Deleted ${#MATCHES[@]} directories (${TOTAL_HUMAN} reclaimed)." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment