2026-06-24 15:49:28 +02:00
|
|
|
#!/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,
|
2026-07-03 13:29:32 +02:00
|
|
|
# browser & IDE caches (only under ~/.cache — never profiles), tool
|
|
|
|
|
# download caches (playwright/puppeteer/electron), ML model caches
|
|
|
|
|
# (huggingface/torch — models re-download on demand!), cargo registry
|
|
|
|
|
# cache, unused rustup toolchains, Flatpak app caches, PlatformIO's
|
|
|
|
|
# .cache — and, only with the explicit --platformio flag, PlatformIO
|
|
|
|
|
# packages/platforms (regenerable via pio, but slow to re-fetch).
|
|
|
|
|
# With --system: apt cache, journald logs, rotated logs, old snap
|
|
|
|
|
# revisions, systemd coredumps.
|
2026-06-24 15:49:28 +02:00
|
|
|
#
|
2026-07-03 13:29:32 +02:00
|
|
|
# What it NEVER touches: your documents, media, ~/projects,
|
|
|
|
|
# ~/homelab-preload, and especially cloud-sync folders (Dropbox / sync /
|
|
|
|
|
# CosmoseGDrive) — those are pruned from every scan. Big mixed dirs
|
|
|
|
|
# (~/.local/share, ~/.config, ~/.cache as a whole) are only REPORTED
|
|
|
|
|
# for manual review, never deleted.
|
2026-06-24 15:49:28 +02:00
|
|
|
#
|
|
|
|
|
# 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)
|
2026-07-03 13:29:32 +02:00
|
|
|
# ./safeclean.sh --platformio # also offer PlatformIO packages/platforms
|
2026-06-24 15:49:28 +02:00
|
|
|
# ./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
|
2026-07-03 13:29:32 +02:00
|
|
|
CLEAN_PLATFORMIO=0 # set via --platformio to also offer ~/.platformio
|
|
|
|
|
# packages/platforms (regenerable via pio, but slow)
|
2026-06-24 15:49:28 +02:00
|
|
|
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"
|
2026-07-03 13:29:32 +02:00
|
|
|
"$HOME/projects" # real project data — hands off
|
|
|
|
|
"$HOME/homelab-preload" # preloaded data, expensive to re-fetch
|
2026-06-24 15:49:28 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ------------------------------- 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 ;;
|
2026-07-03 13:29:32 +02:00
|
|
|
--platformio) CLEAN_PLATFORMIO=1 ;;
|
2026-06-24 15:49:28 +02:00
|
|
|
--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)
|
2026-07-03 13:29:32 +02:00
|
|
|
# print the header comment block (everything up to the first
|
|
|
|
|
# non-comment line), so help never drifts out of sync
|
|
|
|
|
awk 'NR > 1 && !/^#/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "$0"
|
2026-06-24 15:49:28 +02:00
|
|
|
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/tty
|
|
|
|
|
[[ "$ans" =~ ^[Yy] ]]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dir_size() { # total human size of all current TARGETS
|
|
|
|
|
[ ${#TARGETS[@]} -eq 0 ] && { echo "0"; return; }
|
|
|
|
|
du -sch "${TARGETS[@]}" 2>/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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 13:29:32 +02:00
|
|
|
# Browser & IDE caches: only the ~/.cache subtrees — profiles (~/.mozilla,
|
|
|
|
|
# ~/.config/google-chrome, ~/.config/JetBrains) are never touched.
|
|
|
|
|
scan_browser_ide_caches() {
|
|
|
|
|
reset_targets
|
|
|
|
|
local d
|
|
|
|
|
for d in \
|
|
|
|
|
"$HOME/.cache/JetBrains" \
|
|
|
|
|
"$HOME/.cache/google-chrome" \
|
|
|
|
|
"$HOME/.cache/chromium" \
|
|
|
|
|
"$HOME/.cache/mozilla"
|
|
|
|
|
do
|
|
|
|
|
[ -e "$d" ] && TARGETS+=("$d")
|
|
|
|
|
done
|
|
|
|
|
report_and_clean "Browser & IDE caches"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Downloaded browser/runtime bundles; re-fetched on demand by the tool.
|
|
|
|
|
scan_tool_download_caches() {
|
|
|
|
|
reset_targets
|
|
|
|
|
local d
|
|
|
|
|
for d in \
|
|
|
|
|
"$HOME/.cache/ms-playwright" \
|
|
|
|
|
"$HOME/.cache/puppeteer" \
|
|
|
|
|
"$HOME/.cache/electron" \
|
|
|
|
|
"$HOME/.cache/electron-builder"
|
|
|
|
|
do
|
|
|
|
|
[ -e "$d" ] && TARGETS+=("$d")
|
|
|
|
|
done
|
|
|
|
|
report_and_clean "Tool download caches"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ML model caches: fully regenerable, but re-downloading can mean many GB.
|
|
|
|
|
scan_ml_caches() {
|
|
|
|
|
reset_targets
|
|
|
|
|
local d
|
|
|
|
|
for d in "$HOME/.cache/huggingface" "$HOME/.cache/torch"; do
|
|
|
|
|
[ -e "$d" ] && TARGETS+=("$d")
|
|
|
|
|
done
|
|
|
|
|
if [ ${#TARGETS[@]} -gt 0 ]; then
|
|
|
|
|
printf ' %sNote: ML model caches — models re-download on demand (can be many GB).%s\n' \
|
|
|
|
|
"$c_yel" "$c_rst"
|
|
|
|
|
fi
|
|
|
|
|
report_and_clean "ML model caches"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Downloaded .crate archives; cargo re-fetches them on demand.
|
|
|
|
|
# (pip wheels live under ~/.cache/pip, already covered by "Dev & app caches".)
|
|
|
|
|
scan_cargo_registry_cache() {
|
|
|
|
|
reset_targets
|
|
|
|
|
[ -e "$HOME/.cargo/registry/cache" ] && TARGETS+=("$HOME/.cargo/registry/cache")
|
|
|
|
|
report_and_clean "Cargo registry cache"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Old rustup toolchains: regenerable via `rustup toolchain install`, but the
|
|
|
|
|
# default toolchain must survive. If we can't tell which one that is, we skip
|
|
|
|
|
# the whole category rather than guess.
|
|
|
|
|
scan_rustup_toolchains() {
|
|
|
|
|
reset_targets
|
|
|
|
|
local tdir="$HOME/.rustup/toolchains" def="" d
|
|
|
|
|
if [ -d "$tdir" ]; then
|
|
|
|
|
if command -v rustup >/dev/null 2>&1; then
|
|
|
|
|
def=$(rustup show active-toolchain 2>/dev/null | awk 'NR==1{print $1}')
|
|
|
|
|
fi
|
|
|
|
|
if [ -z "$def" ] && [ -f "$HOME/.rustup/settings.toml" ]; then
|
|
|
|
|
def=$(awk -F'"' '/^default_toolchain/{print $2}' "$HOME/.rustup/settings.toml")
|
|
|
|
|
fi
|
|
|
|
|
if [ -z "$def" ]; then
|
|
|
|
|
printf ' %-26s %scannot determine default toolchain — skipped%s\n' \
|
|
|
|
|
"Rust toolchains (unused)" "$c_dim" "$c_rst"
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
for d in "$tdir"/*/; do
|
|
|
|
|
d="${d%/}"
|
|
|
|
|
[ -d "$d" ] || continue
|
|
|
|
|
[ "$(basename "$d")" = "$def" ] && continue
|
|
|
|
|
TARGETS+=("$d")
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
report_and_clean "Rust toolchains (unused)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scan_flatpak_caches() {
|
|
|
|
|
reset_targets
|
|
|
|
|
local d
|
|
|
|
|
for d in "$HOME"/.var/app/*/cache; do
|
|
|
|
|
[ -d "$d" ] && TARGETS+=("$d")
|
|
|
|
|
done
|
|
|
|
|
report_and_clean "Flatpak app caches"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# PlatformIO packages/platforms: regenerable (pio re-downloads on the next
|
|
|
|
|
# build) but slow to re-fetch, so only offered behind the explicit
|
|
|
|
|
# --platformio flag. ~/.platformio/.cache is always covered ("Dev & app
|
|
|
|
|
# caches" above).
|
|
|
|
|
scan_platformio_packages() {
|
|
|
|
|
local pkgs="$HOME/.platformio/packages" plats="$HOME/.platformio/platforms"
|
|
|
|
|
if [ "$CLEAN_PLATFORMIO" = 0 ]; then
|
|
|
|
|
if [ -d "$pkgs" ] || [ -d "$plats" ]; then
|
|
|
|
|
local sz
|
|
|
|
|
sz=$(du -sch "$pkgs" "$plats" 2>/dev/null | tail -n1 | awk '{print $1}')
|
|
|
|
|
printf ' %-26s %s%s — skipped (opt in with --platformio)%s\n' \
|
|
|
|
|
"PlatformIO pkgs/platforms" "$c_dim" "${sz:-?}" "$c_rst"
|
|
|
|
|
fi
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
reset_targets
|
|
|
|
|
local d
|
|
|
|
|
for d in "$pkgs" "$plats"; do
|
|
|
|
|
[ -e "$d" ] && TARGETS+=("$d")
|
|
|
|
|
done
|
|
|
|
|
if [ ${#TARGETS[@]} -gt 0 ]; then
|
|
|
|
|
printf ' %sWARNING: pio will re-download packages/platforms on the next build (slow).%s\n' \
|
|
|
|
|
"$c_yel" "$c_rst"
|
|
|
|
|
fi
|
|
|
|
|
report_and_clean "PlatformIO pkgs/platforms"
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 15:49:28 +02:00
|
|
|
# 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
|
2026-06-30 19:09:07 +02:00
|
|
|
|
|
|
|
|
local age_h=$(( AGE_DAYS * 24 ))
|
|
|
|
|
|
2026-06-24 15:49:28 +02:00
|
|
|
echo " Docker reclaimable space:"
|
|
|
|
|
docker system df 2>/dev/null | sed 's/^/ /'
|
2026-06-30 19:09:07 +02:00
|
|
|
|
|
|
|
|
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
|
2026-06-24 15:49:28 +02:00
|
|
|
|
|
|
|
|
local flags="-f"
|
|
|
|
|
[ "$AGGRESSIVE_DOCKER" = 1 ] && flags="-af" # -a also removes unused (not just dangling) images
|
|
|
|
|
local until_args=()
|
2026-06-30 19:09:07 +02:00
|
|
|
[ "$AGE_DAYS" -gt 0 ] && until_args=(--filter "until=${age_h}h")
|
2026-06-24 15:49:28 +02:00
|
|
|
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
|
2026-07-03 13:29:32 +02:00
|
|
|
echo " ${c_dim}dry-run: would offer apt clean, journald vacuum, rotated log cleanup, old snap removal, systemd coredump cleanup${c_rst}"
|
2026-06-24 15:49:28 +02:00
|
|
|
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
|
2026-07-03 13:29:32 +02:00
|
|
|
echo " systemd coredumps:"; sudo -n du -sh /var/lib/systemd/coredump 2>/dev/null | sed 's/^/ /' || true
|
2026-06-30 19:09:07 +02:00
|
|
|
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"
|
2026-06-24 15:49:28 +02:00
|
|
|
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
|
|
|
|
|
|
2026-06-30 19:09:07 +02:00
|
|
|
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
|
|
|
|
|
|
2026-07-03 13:29:32 +02:00
|
|
|
if [ -d /var/lib/systemd/coredump ]; then
|
|
|
|
|
local cd_size
|
|
|
|
|
cd_size=$(sudo du -sh /var/lib/systemd/coredump 2>/dev/null | awk '{print $1}')
|
|
|
|
|
if confirm "systemd coredumps: delete dumps older than ${AGE_DAYS} day(s) (dir total: ${cd_size:-?})?"; then
|
|
|
|
|
if [ "$AGE_DAYS" -gt 0 ]; then
|
|
|
|
|
sudo find /var/lib/systemd/coredump -type f -mtime "+$AGE_DAYS" -delete
|
|
|
|
|
else
|
|
|
|
|
sudo find /var/lib/systemd/coredump -type f -delete
|
|
|
|
|
fi
|
|
|
|
|
printf ' %sremoved.%s\n' "$c_grn" "$c_rst"
|
|
|
|
|
else
|
|
|
|
|
echo " skipped."
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-06-24 15:49:28 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 13:29:32 +02:00
|
|
|
# Report-only: the biggest subdirectories of trees that MIX regenerable junk
|
|
|
|
|
# with real data (app state, settings, histories). This function never
|
|
|
|
|
# deletes anything and never feeds TARGETS — the owner reviews by hand.
|
|
|
|
|
report_large_dirs() {
|
|
|
|
|
echo
|
|
|
|
|
echo "${c_bold}Large dirs for MANUAL review (NEVER deleted by this script) ─${c_rst}"
|
|
|
|
|
echo " ${c_dim}Top 15 subdirectories of ~/.local/share, ~/.config and ~/.cache by size:${c_rst}"
|
|
|
|
|
local r
|
|
|
|
|
{
|
|
|
|
|
for r in "$HOME/.local/share" "$HOME/.config" "$HOME/.cache"; do
|
|
|
|
|
# per-subdir sizes; drop du's trailing total line for the root itself
|
|
|
|
|
[ -d "$r" ] && du -h --max-depth=1 "$r" 2>/dev/null | sed '$d'
|
|
|
|
|
done
|
|
|
|
|
} | sort -rh | head -n 15 | sed 's/^/ /'
|
|
|
|
|
echo " ${c_dim}Review by hand: du -sh <path> — these may hold real data, so decide yourself.${c_rst}"
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 15:49:28 +02:00
|
|
|
# --------------------------------- 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/^/ /'
|
2026-06-30 19:09:07 +02:00
|
|
|
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
|
2026-06-24 15:49:28 +02:00
|
|
|
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
|
2026-07-03 13:29:32 +02:00
|
|
|
scan_browser_ide_caches
|
|
|
|
|
scan_tool_download_caches
|
|
|
|
|
scan_ml_caches
|
|
|
|
|
scan_cargo_registry_cache
|
|
|
|
|
scan_rustup_toolchains
|
|
|
|
|
scan_flatpak_caches
|
|
|
|
|
scan_platformio_packages
|
2026-06-24 15:49:28 +02:00
|
|
|
clean_go_cache
|
|
|
|
|
clean_docker
|
|
|
|
|
|
|
|
|
|
[ "$DO_SYSTEM" = 1 ] && clean_system
|
|
|
|
|
|
2026-07-03 13:29:32 +02:00
|
|
|
report_large_dirs
|
|
|
|
|
|
2026-06-24 15:49:28 +02:00
|
|
|
echo
|
|
|
|
|
if [ "$DRY_RUN" = 1 ]; then
|
|
|
|
|
echo "Done. (this was a dry-run — add --apply to delete)"
|
|
|
|
|
else
|
|
|
|
|
echo "Done."
|
|
|
|
|
fi
|