Skip to content

Instantly share code, notes, and snippets.

@TimMikeladze
Created August 17, 2026 07:11
Show Gist options
  • Select an option

  • Save TimMikeladze/97a01d74edc1dcac25dc941ce85af0d1 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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