#!/usr/bin/env bash # # safeclean.sh — report and (optionally) remove regenerable junk only. # # Default mode is DRY-RUN: it scans, sizes everything up, and deletes NOTHING. # Add --apply to actually delete (you'll still be asked per-category). # # What it targets (all regenerable / safe to lose): # core dumps, Python caches, virtualenvs, node_modules, build targets, # ~/.m2, gradle/pip/npm/yarn/go caches, trash, thumbnails, Docker cruft, # and (with --system) apt cache, journald logs, old snap revisions. # # What it NEVER touches: your documents, media, and especially cloud-sync # folders (Dropbox / sync / CosmoseGDrive) — those are pruned from every scan. # # Usage: # ./safeclean.sh # report only (safe, default) # ./safeclean.sh --apply # delete, asking before each category # ./safeclean.sh --apply -y # delete without per-category prompts # ./safeclean.sh --apply --system # also clean system stuff (uses sudo) # ./safeclean.sh --root /path # scan a different tree (default: $HOME) # ./safeclean.sh --days 30 # only items untouched for 30+ days (default 7) # ./safeclean.sh --no-age # ignore age, consider everything eligible # ./safeclean.sh -v # also list every path found # set -uo pipefail # ----------------------------- configuration -------------------------------- SCAN_ROOT="$HOME" DRY_RUN=1 ASSUME_YES=0 DO_SYSTEM=0 VERBOSE=0 AGGRESSIVE_DOCKER=0 # set via --docker-all to drop ALL unused images AGE_DAYS=7 # only touch items NOT modified within this many days; # set to 0 (or --no-age) to ignore age entirely # Directories never scanned or descended into. Cloud-sync dirs are here on # purpose: deleting a node_modules inside Dropbox would sync the deletion. PRUNE_DIRS=( "$HOME/Dropbox" "$HOME/sync" "$HOME/CosmoseGDrive" "$HOME/.cache" # handled explicitly below "$HOME/.local/share/Trash" "$HOME/Android" # SDK; expensive to re-fetch "$HOME/snap" # per-app snap data "$HOME/.steam" "$HOME/Steam" ) # ------------------------------- arg parsing -------------------------------- while [ $# -gt 0 ]; do case "$1" in --apply) DRY_RUN=0 ;; -y|--yes) ASSUME_YES=1 ;; --system) DO_SYSTEM=1 ;; --docker-all) AGGRESSIVE_DOCKER=1 ;; --days) shift; AGE_DAYS="${1:?--days needs a number}" ;; --no-age) AGE_DAYS=0 ;; -v|--verbose) VERBOSE=1 ;; --root) shift; SCAN_ROOT="${1:?--root needs a path}" ;; -h|--help) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) echo "unknown option: $1 (try --help)" >&2; exit 2 ;; esac shift done if [ ! -d "$SCAN_ROOT" ]; then echo "scan root '$SCAN_ROOT' is not a directory" >&2; exit 2 fi case "$AGE_DAYS" in ''|*[!0-9]*) echo "--days needs a non-negative integer" >&2; exit 2 ;; esac # --------------------------------- helpers ---------------------------------- c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_rst=$'\033[0m' declare -a TARGETS reset_targets() { TARGETS=(); } # Refuse to ever queue something catastrophic. safe_to_delete() { local p="$1" [ -n "$p" ] || return 1 [ "$p" != "/" ] || return 1 [ "$p" != "$HOME" ] || return 1 [ "$p" != "$SCAN_ROOT" ] || return 1 return 0 } confirm() { [ "$ASSUME_YES" = 1 ] && return 0 local ans read -rp " ${c_yel}$1${c_rst} [y/N] " ans /dev/null | tail -n1 | awk '{print $1}' } # True (0) if PATH has NOT been modified within AGE_DAYS, i.e. it is "old" # enough to delete. We look at the newest file *inside* (not just the dir's # own mtime), so an actively used venv/cache touched this week is kept. is_old() { [ "$AGE_DAYS" -le 0 ] && return 0 # age filter disabled local p="$1" mins=$(( AGE_DAYS * 1440 )) # if find turns up anything modified more recently than the window, keep it if find "$p" -mmin "-$mins" -print -quit 2>/dev/null | grep -q .; then return 1 fi return 0 } # Keep only items in TARGETS that are old enough. filter_by_age() { [ "$AGE_DAYS" -le 0 ] && return local kept=() p for p in "${TARGETS[@]}"; do is_old "$p" && kept+=("$p"); done TARGETS=("${kept[@]}") } # rm that copes with read-only trees (e.g. Go's module cache is mode 0444). safe_rm() { rm -rf -- "$@" 2>/dev/null && return 0 chmod -R u+w -- "$@" 2>/dev/null rm -rf -- "$@" 2>/dev/null } report_and_clean() { local label="$1" # drop anything unsafe that slipped in local kept=(); local p for p in "${TARGETS[@]}"; do safe_to_delete "$p" && kept+=("$p"); done TARGETS=("${kept[@]}") filter_by_age # keep only items untouched for >= AGE_DAYS days if [ ${#TARGETS[@]} -eq 0 ]; then printf ' %-26s %snothing found%s\n' "$label" "$c_dim" "$c_rst" return fi local size; size=$(dir_size) printf ' %-26s %s%s%s %s(%d item(s))%s\n' \ "$label" "$c_bold" "$size" "$c_rst" "$c_dim" "${#TARGETS[@]}" "$c_rst" if [ "$VERBOSE" = 1 ]; then printf ' %s\n' "${TARGETS[@]}" fi [ "$DRY_RUN" = 1 ] && return if confirm "Delete ${label} (${size})?"; then safe_rm "${TARGETS[@]}" && printf ' %sremoved.%s\n' "$c_grn" "$c_rst" else echo " skipped." fi } # Build the -prune expression for find from PRUNE_DIRS. PRUNE_ARGS=() build_prune() { PRUNE_ARGS=(); local first=1 d for d in "${PRUNE_DIRS[@]}"; do if [ "$first" = 1 ]; then PRUNE_ARGS+=( -path "$d" ); first=0 else PRUNE_ARGS+=( -o -path "$d" ); fi done } build_prune # ------------------------------- scanners ----------------------------------- scan_core_dumps() { reset_targets local p while IFS= read -r -d '' p; do TARGETS+=("$p"); done < <( find "$SCAN_ROOT" -maxdepth 1 -type f \ \( -name 'core.[0-9]*' -o -name 'core' \) -print0 2>/dev/null ) report_and_clean "Core dumps" } scan_python_caches() { reset_targets local p while IFS= read -r -d '' p; do TARGETS+=("$p"); done < <( find "$SCAN_ROOT" \( "${PRUNE_ARGS[@]}" \) -prune -o \ -type d \( -name '__pycache__' -o -name '.pytest_cache' \ -o -name '.mypy_cache' -o -name '.ruff_cache' \ -o -name '.ipynb_checkpoints' \) -print0 2>/dev/null ) report_and_clean "Python caches" } scan_virtualenvs() { reset_targets local cfg while IFS= read -r -d '' cfg; do TARGETS+=("$(dirname "$cfg")"); done < <( find "$SCAN_ROOT" \( "${PRUNE_ARGS[@]}" \) -prune -o \ -type f -name 'pyvenv.cfg' -print0 2>/dev/null ) report_and_clean "Python virtualenvs" } scan_node_modules() { reset_targets local p while IFS= read -r -d '' p; do TARGETS+=("$p"); done < <( find "$SCAN_ROOT" \( "${PRUNE_ARGS[@]}" \) -prune -o \ -type d -name 'node_modules' -print0 -prune 2>/dev/null ) report_and_clean "node_modules" } scan_build_targets() { # only 'target' dirs that sit next to a real build manifest (Rust/Maven/Gradle) reset_targets local p parent while IFS= read -r -d '' p; do parent="$(dirname "$p")" if [ -f "$parent/Cargo.toml" ] || [ -f "$parent/pom.xml" ] \ || [ -f "$parent/build.gradle" ] || [ -f "$parent/build.gradle.kts" ]; then TARGETS+=("$p") fi done < <( find "$SCAN_ROOT" \( "${PRUNE_ARGS[@]}" \) -prune -o \ -type d -name 'target' -print0 -prune 2>/dev/null ) report_and_clean "Build targets (rust/maven)" } scan_user_caches() { reset_targets local d for d in \ "$HOME/.m2/repository" \ "$HOME/.gradle/caches" \ "$HOME/.cache/pip" \ "$HOME/.npm/_cacache" \ "$HOME/.cache/yarn" \ "$HOME/.platformio/.cache" \ "$HOME/.cache/thumbnails" \ "$HOME/.local/share/Trash" do [ -e "$d" ] && TARGETS+=("$d") done report_and_clean "Dev & app caches" } # Go's caches need their own tool: module cache files are read-only, so # `go clean` is the correct way to drop them (with a chmod+rm fallback). clean_go_cache() { local modcache="$HOME/go/pkg/mod" buildcache="$HOME/.cache/go-build" local present=() [ -d "$modcache" ] && is_old "$modcache" && present+=("$modcache") [ -d "$buildcache" ] && is_old "$buildcache" && present+=("$buildcache") if [ ${#present[@]} -eq 0 ]; then printf ' %-26s %snothing found%s\n' "Go caches" "$c_dim" "$c_rst"; return fi local size; size=$(du -sch "${present[@]}" 2>/dev/null | tail -n1 | awk '{print $1}') printf ' %-26s %s%s%s\n' "Go caches" "$c_bold" "$size" "$c_rst" [ "$DRY_RUN" = 1 ] && return if confirm "Delete Go module + build cache (${size})?"; then if command -v go >/dev/null 2>&1; then go clean -modcache 2>/dev/null go clean -cache 2>/dev/null fi safe_rm "${present[@]}" printf ' %sremoved.%s\n' "$c_grn" "$c_rst" else echo " skipped." fi } clean_docker() { command -v docker >/dev/null 2>&1 || { printf ' %-26s %sdocker not installed%s\n' "Docker" "$c_dim" "$c_rst"; return; } if ! docker info >/dev/null 2>&1; then printf ' %-26s %sdaemon not reachable%s\n' "Docker" "$c_dim" "$c_rst"; return fi local age_h=$(( AGE_DAYS * 24 )) echo " Docker reclaimable space:" docker system df 2>/dev/null | sed 's/^/ /' if [ "$DRY_RUN" = 1 ]; then if [ "$AGGRESSIVE_DOCKER" = 0 ] && [ "$AGE_DAYS" -gt 0 ]; then # apply uses --filter until=Xh, so docker system df overstates actual reclaim. # Estimate: sum sizes of dangling images older than age_h hours (what prune targets). local img_bytes=0 img_count=0 id s while IFS= read -r id; do [ -z "$id" ] && continue img_count=$(( img_count + 1 )) s=$(docker image inspect --format '{{.Size}}' "$id" 2>/dev/null) || s=0 img_bytes=$(( img_bytes + ${s:-0} )) done < <(docker image ls --filter "dangling=true" --filter "until=${age_h}h" \ --format "{{.ID}}" 2>/dev/null) local img_human img_human=$(numfmt --to=si --suffix=B -- "$img_bytes" 2>/dev/null \ || echo "${img_bytes}B") echo printf ' %sNOTE: apply uses --filter until=%dh — only items older than %d day(s) are removed.%s\n' \ "$c_yel" "$age_h" "$AGE_DAYS" "$c_rst" printf ' Dangling images >%dd apply would actually remove: %s%s%s (%d image(s))\n' \ "$AGE_DAYS" "$c_bold" "$img_human" "$c_rst" "$img_count" printf ' %s(stopped containers + build cache also contribute; totals shown above)%s\n' \ "$c_dim" "$c_rst" printf ' %sTip: --docker-all removes ALL unused images regardless of age.%s\n' \ "$c_dim" "$c_rst" fi return fi local flags="-f" [ "$AGGRESSIVE_DOCKER" = 1 ] && flags="-af" # -a also removes unused (not just dangling) images local until_args=() [ "$AGE_DAYS" -gt 0 ] && until_args=(--filter "until=${age_h}h") if confirm "Run 'docker system prune ${flags} ${until_args[*]}' (volumes are NOT touched)?"; then docker system prune $flags "${until_args[@]}" else echo " skipped." fi } clean_system() { echo echo "${c_bold}System (sudo) ─────────────────────────────────────────────${c_rst}" if [ "$DRY_RUN" = 1 ]; then echo " ${c_dim}dry-run: would offer apt clean, journald vacuum, rotated log cleanup, old snap removal${c_rst}" echo " apt archive cache:"; sudo -n du -sh /var/cache/apt/archives 2>/dev/null | sed 's/^/ /' || true echo " journal logs:"; journalctl --disk-usage 2>/dev/null | sed 's/^/ /' || true echo " rotated logs (/var/log/*.gz, *.1, *.2 …):" local rot_total rot_total=$(find /var/log -maxdepth 1 -type f \ \( -name '*.gz' -o -name '*.[0-9]' -o -name '*.[0-9][0-9]' \) \ -print0 2>/dev/null | xargs -0 -r du -sch 2>/dev/null \ | tail -n1 | awk '{print $1}') || rot_total="" printf ' %s%s%s\n' "$c_bold" "${rot_total:-0}" "$c_rst" return fi if confirm "apt: clean download cache + autoremove unused packages?"; then sudo apt-get clean sudo apt-get autoremove --purge -y fi local jdays="$AGE_DAYS"; [ "$jdays" -lt 1 ] && jdays=7 if confirm "journald: vacuum logs older than ${jdays} days?"; then sudo journalctl --vacuum-time="${jdays}d" fi local rotated=() while IFS= read -r -d '' f; do rotated+=("$f"); done < <( find /var/log -maxdepth 1 -type f \ \( -name '*.gz' -o -name '*.[0-9]' -o -name '*.[0-9][0-9]' \) \ -print0 2>/dev/null ) if [ ${#rotated[@]} -gt 0 ]; then local rot_size rot_size=$(du -sch "${rotated[@]}" 2>/dev/null | tail -n1 | awk '{print $1}') if confirm "Delete rotated system logs (${rot_size}, ${#rotated[@]} file(s) — not active logs)?"; then sudo rm -f -- "${rotated[@]}" && printf ' %sremoved.%s\n' "$c_grn" "$c_rst" else echo " skipped." fi fi if confirm "snap: remove disabled (old) revisions?"; then snap list --all 2>/dev/null | awk '/disabled/{print $1, $3}' | \ while read -r name rev; do sudo snap remove "$name" --revision="$rev" done fi } # --------------------------------- run -------------------------------------- echo "${c_bold}safeclean${c_rst} — root: $SCAN_ROOT" if [ "$DRY_RUN" = 1 ]; then echo "${c_grn}DRY-RUN: reporting only, nothing will be deleted. Re-run with --apply to act.${c_rst}" else msg="APPLY MODE: you will be prompted before each category." [ "$ASSUME_YES" = 1 ] && msg="APPLY MODE: deleting without prompts (auto-yes ON)." echo "${c_yel}${msg}${c_rst}" fi echo if [ "$AGE_DAYS" -gt 0 ]; then echo "${c_dim}Age filter: only items untouched for ${AGE_DAYS}+ days are eligible (--no-age to disable).${c_rst}" else echo "${c_dim}Age filter: OFF — eligibility ignores file age.${c_rst}" fi echo echo "${c_bold}Largest items under $SCAN_ROOT:${c_rst}" du -h --max-depth=1 "$SCAN_ROOT" 2>/dev/null | sort -rh | head -n 12 | sed 's/^/ /' if [ -d "${SCAN_ROOT}/.Private" ] && mount 2>/dev/null | grep -q " on ${SCAN_ROOT} type ecryptfs"; then printf ' %sNote: eCryptfs detected — .Private and home directory are the same data; sizes above may be double-counted.%s\n' "$c_yel" "$c_rst" fi echo echo "${c_bold}Reclaimable categories ──────────────────────────────────────${c_rst}" scan_core_dumps scan_python_caches scan_virtualenvs scan_node_modules scan_build_targets scan_user_caches clean_go_cache clean_docker [ "$DO_SYSTEM" = 1 ] && clean_system echo if [ "$DRY_RUN" = 1 ]; then echo "Done. (this was a dry-run — add --apply to delete)" else echo "Done." fi