feat(safeclean): add regenerable cache categories + manual-review report for large dirs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oskar Kapala 2026-07-03 13:29:32 +02:00
parent 37233f0a47
commit 3e0c91d70a

View file

@ -8,16 +8,27 @@
# What it targets (all regenerable / safe to lose): # What it targets (all regenerable / safe to lose):
# core dumps, Python caches, virtualenvs, node_modules, build targets, # core dumps, Python caches, virtualenvs, node_modules, build targets,
# ~/.m2, gradle/pip/npm/yarn/go caches, trash, thumbnails, Docker cruft, # ~/.m2, gradle/pip/npm/yarn/go caches, trash, thumbnails, Docker cruft,
# and (with --system) apt cache, journald logs, old snap revisions. # 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.
# #
# What it NEVER touches: your documents, media, and especially cloud-sync # What it NEVER touches: your documents, media, ~/projects,
# folders (Dropbox / sync / CosmoseGDrive) — those are pruned from every scan. # ~/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.
# #
# Usage: # Usage:
# ./safeclean.sh # report only (safe, default) # ./safeclean.sh # report only (safe, default)
# ./safeclean.sh --apply # delete, asking before each category # ./safeclean.sh --apply # delete, asking before each category
# ./safeclean.sh --apply -y # delete without per-category prompts # ./safeclean.sh --apply -y # delete without per-category prompts
# ./safeclean.sh --apply --system # also clean system stuff (uses sudo) # ./safeclean.sh --apply --system # also clean system stuff (uses sudo)
# ./safeclean.sh --platformio # also offer PlatformIO packages/platforms
# ./safeclean.sh --root /path # scan a different tree (default: $HOME) # ./safeclean.sh --root /path # scan a different tree (default: $HOME)
# ./safeclean.sh --days 30 # only items untouched for 30+ days (default 7) # ./safeclean.sh --days 30 # only items untouched for 30+ days (default 7)
# ./safeclean.sh --no-age # ignore age, consider everything eligible # ./safeclean.sh --no-age # ignore age, consider everything eligible
@ -33,6 +44,8 @@ ASSUME_YES=0
DO_SYSTEM=0 DO_SYSTEM=0
VERBOSE=0 VERBOSE=0
AGGRESSIVE_DOCKER=0 # set via --docker-all to drop ALL unused images AGGRESSIVE_DOCKER=0 # set via --docker-all to drop ALL unused images
CLEAN_PLATFORMIO=0 # set via --platformio to also offer ~/.platformio
# packages/platforms (regenerable via pio, but slow)
AGE_DAYS=7 # only touch items NOT modified within this many days; AGE_DAYS=7 # only touch items NOT modified within this many days;
# set to 0 (or --no-age) to ignore age entirely # set to 0 (or --no-age) to ignore age entirely
@ -48,6 +61,8 @@ PRUNE_DIRS=(
"$HOME/snap" # per-app snap data "$HOME/snap" # per-app snap data
"$HOME/.steam" "$HOME/.steam"
"$HOME/Steam" "$HOME/Steam"
"$HOME/projects" # real project data — hands off
"$HOME/homelab-preload" # preloaded data, expensive to re-fetch
) )
# ------------------------------- arg parsing -------------------------------- # ------------------------------- arg parsing --------------------------------
@ -58,12 +73,15 @@ while [ $# -gt 0 ]; do
-y|--yes) ASSUME_YES=1 ;; -y|--yes) ASSUME_YES=1 ;;
--system) DO_SYSTEM=1 ;; --system) DO_SYSTEM=1 ;;
--docker-all) AGGRESSIVE_DOCKER=1 ;; --docker-all) AGGRESSIVE_DOCKER=1 ;;
--platformio) CLEAN_PLATFORMIO=1 ;;
--days) shift; AGE_DAYS="${1:?--days needs a number}" ;; --days) shift; AGE_DAYS="${1:?--days needs a number}" ;;
--no-age) AGE_DAYS=0 ;; --no-age) AGE_DAYS=0 ;;
-v|--verbose) VERBOSE=1 ;; -v|--verbose) VERBOSE=1 ;;
--root) shift; SCAN_ROOT="${1:?--root needs a path}" ;; --root) shift; SCAN_ROOT="${1:?--root needs a path}" ;;
-h|--help) -h|--help)
sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//' # 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"
exit 0 ;; exit 0 ;;
*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;; *) echo "unknown option: $1 (try --help)" >&2; exit 2 ;;
esac esac
@ -249,6 +267,123 @@ scan_user_caches() {
report_and_clean "Dev & app caches" report_and_clean "Dev & app caches"
} }
# 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"
}
# Go's caches need their own tool: module cache files are read-only, so # 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). # `go clean` is the correct way to drop them (with a chmod+rm fallback).
clean_go_cache() { clean_go_cache() {
@ -328,9 +463,10 @@ clean_system() {
echo echo
echo "${c_bold}System (sudo) ─────────────────────────────────────────────${c_rst}" echo "${c_bold}System (sudo) ─────────────────────────────────────────────${c_rst}"
if [ "$DRY_RUN" = 1 ]; then 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 " ${c_dim}dry-run: would offer apt clean, journald vacuum, rotated log cleanup, old snap removal, systemd coredump cleanup${c_rst}"
echo " apt archive cache:"; sudo -n du -sh /var/cache/apt/archives 2>/dev/null | sed 's/^/ /' || true 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 " journal logs:"; journalctl --disk-usage 2>/dev/null | sed 's/^/ /' || true
echo " systemd coredumps:"; sudo -n du -sh /var/lib/systemd/coredump 2>/dev/null | sed 's/^/ /' || true
echo " rotated logs (/var/log/*.gz, *.1, *.2 …):" echo " rotated logs (/var/log/*.gz, *.1, *.2 …):"
local rot_total local rot_total
rot_total=$(find /var/log -maxdepth 1 -type f \ rot_total=$(find /var/log -maxdepth 1 -type f \
@ -367,6 +503,21 @@ clean_system() {
fi fi
fi fi
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
if confirm "snap: remove disabled (old) revisions?"; then if confirm "snap: remove disabled (old) revisions?"; then
snap list --all 2>/dev/null | awk '/disabled/{print $1, $3}' | \ snap list --all 2>/dev/null | awk '/disabled/{print $1, $3}' | \
while read -r name rev; do while read -r name rev; do
@ -375,6 +526,23 @@ clean_system() {
fi fi
} }
# 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}"
}
# --------------------------------- run -------------------------------------- # --------------------------------- run --------------------------------------
echo "${c_bold}safeclean${c_rst} — root: $SCAN_ROOT" echo "${c_bold}safeclean${c_rst} — root: $SCAN_ROOT"
@ -407,11 +575,20 @@ scan_virtualenvs
scan_node_modules scan_node_modules
scan_build_targets scan_build_targets
scan_user_caches scan_user_caches
scan_browser_ide_caches
scan_tool_download_caches
scan_ml_caches
scan_cargo_registry_cache
scan_rustup_toolchains
scan_flatpak_caches
scan_platformio_packages
clean_go_cache clean_go_cache
clean_docker clean_docker
[ "$DO_SYSTEM" = 1 ] && clean_system [ "$DO_SYSTEM" = 1 ] && clean_system
report_large_dirs
echo echo
if [ "$DRY_RUN" = 1 ]; then if [ "$DRY_RUN" = 1 ]; then
echo "Done. (this was a dry-run — add --apply to delete)" echo "Done. (this was a dry-run — add --apply to delete)"