From f4486d3c3ce2c0f1b2586e89f84d95a3c86e7e48 Mon Sep 17 00:00:00 2001 From: Oskar Kapala Date: Wed, 24 Jun 2026 15:49:28 +0200 Subject: [PATCH] Add safeclean.sh Co-Authored-By: Claude Opus 4.8 --- safeclean.sh | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 safeclean.sh diff --git a/safeclean.sh b/safeclean.sh new file mode 100644 index 0000000..ddcd476 --- /dev/null +++ b/safeclean.sh @@ -0,0 +1,364 @@ +#!/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 + echo " Docker reclaimable space:" + docker system df 2>/dev/null | sed 's/^/ /' + [ "$DRY_RUN" = 1 ] && return + + 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_DAYS * 24 ))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, 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 + 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 + + 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/^/ /' +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