#!/bin/bash # Mole - Clean command. # Runs cleanup modules with optional sudo. # Supports dry-run and whitelist. set -euo pipefail # User state and installed tools must never run with inherited root privileges. # Individual maintenance operations request administrator access themselves. if [[ "$EUID" -eq 0 ]]; then printf '%s\n' 'Run Mole without sudo; it requests administrator access when needed.' >&2 exit 1 fi export LC_ALL=C export LANG=C SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/core/common.sh" source "$SCRIPT_DIR/../lib/core/sudo.sh" source "$SCRIPT_DIR/../lib/clean/brew.sh" source "$SCRIPT_DIR/../lib/clean/caches.sh" source "$SCRIPT_DIR/../lib/clean/apps.sh" source "$SCRIPT_DIR/../lib/clean/dev.sh" source "$SCRIPT_DIR/../lib/clean/app_caches.sh" source "$SCRIPT_DIR/../lib/clean/hints.sh" source "$SCRIPT_DIR/../lib/clean/system.sh" source "$SCRIPT_DIR/../lib/clean/user.sh" SYSTEM_CLEAN=false DRY_RUN=false if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then DRY_RUN=true fi PROTECT_FINDER_METADATA=false EXTERNAL_VOLUME_TARGET="" IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false") # Whitelist and preview belong to the invoking user even when the whole # command runs as root. Root dry-runs stage preview content in a root-owned # file and publish it through an invoking-user process so user-controlled # symlinks are never opened for writing with root privileges. See #1210. MOLE_USER_HOME="$(get_invoking_home)" [[ -n "$MOLE_USER_HOME" ]] || MOLE_USER_HOME="$HOME" load_mole_whitelist "$MOLE_USER_HOME" || exit 1 CLEAN_PREVIEW_FINAL_FILE="$MOLE_USER_HOME/.config/mole/clean-list.txt" CLEAN_PREVIEW_STAGING_FILE="" CLEAN_PREVIEW_LEDGER_FILE="" EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE" CURRENT_SECTION="" readonly PROTECTED_SW_DOMAINS=( # Web editors "capcut.com" "photopea.com" "pixlr.com" # Google Workspace (offline mode) "docs.google.com" "sheets.google.com" "slides.google.com" "drive.google.com" "mail.google.com" # Code platforms (offline/PWA) "github.com" "gitlab.com" "codepen.io" "codesandbox.io" "replit.com" "stackblitz.com" # Collaboration tools (offline/PWA) "notion.so" "figma.com" "linear.app" "excalidraw.com" ) prepare_clean_preview_file() { EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE" CLEAN_PREVIEW_STAGING_FILE="" CLEAN_PREVIEW_LEDGER_FILE="" if is_root_user && [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then ensure_mole_temp_root || return 1 local root_temp_dir="$MOLE_RESOLVED_TMPDIR" CLEAN_PREVIEW_STAGING_FILE=$(umask 077 && mktemp "$root_temp_dir/mole.clean-preview.XXXXXX") || return 1 [[ -f "$CLEAN_PREVIEW_STAGING_FILE" && ! -L "$CLEAN_PREVIEW_STAGING_FILE" && -O "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 1 MOLE_TEMP_FILES+=("$CLEAN_PREVIEW_STAGING_FILE") EXPORT_LIST_FILE="$CLEAN_PREVIEW_STAGING_FILE" else ensure_user_file "$EXPORT_LIST_FILE" fi CLEAN_PREVIEW_LEDGER_FILE=$(create_temp_file) || return 1 [[ -f "$CLEAN_PREVIEW_LEDGER_FILE" && ! -L "$CLEAN_PREVIEW_LEDGER_FILE" ]] || return 1 : > "$CLEAN_PREVIEW_LEDGER_FILE" } run_clean_preview_as_invoking_user() { /usr/bin/sudo -u "$SUDO_USER" -- "$@" } publish_clean_preview_file() { [[ -n "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 0 [[ -f "$CLEAN_PREVIEW_STAGING_FILE" && ! -L "$CLEAN_PREVIEW_STAGING_FILE" && -O "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 1 [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]] || return 1 local final_dir final_dir=$(dirname "$CLEAN_PREVIEW_FINAL_FILE") run_clean_preview_as_invoking_user /bin/mkdir -p "$final_dir" 2> /dev/null || return 1 if ! /bin/cat "$CLEAN_PREVIEW_STAGING_FILE" | run_clean_preview_as_invoking_user /usr/bin/tee "$CLEAN_PREVIEW_FINAL_FILE" > /dev/null; then return 1 fi EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE" return 0 } if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then for entry in "${WHITELIST_PATTERNS[@]}"; do if [[ "$entry" == "$FINDER_METADATA_SENTINEL" ]]; then PROTECT_FINDER_METADATA=true break fi done fi # Section tracking and summary counters. total_items=0 TRACK_SECTION=0 SECTION_ACTIVITY=0 files_cleaned=0 total_size_cleaned=0 whitelist_skipped_count=0 PROJECT_ARTIFACT_HINT_DETECTED=false PROJECT_ARTIFACT_HINT_COUNT=0 PROJECT_ARTIFACT_HINT_TRUNCATED=false PROJECT_ARTIFACT_HINT_EXAMPLES=() PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0 PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false declare -a DRY_RUN_SEEN_IDENTITIES=() DRY_RUN_TOTAL_PARTIAL=false declare -a DEFERRED_CLEANUP_FAMILIES=() # Record expected process-state skips without turning every protected target # into a separate warning row. Unknown process state is not recorded here and # remains visible at the call site. # shellcheck disable=SC2329 defer_cleanup_family() { local family="${1:-}" local existing [[ -n "$family" ]] || return 0 if [[ ${#DEFERRED_CLEANUP_FAMILIES[@]} -gt 0 ]]; then for existing in "${DEFERRED_CLEANUP_FAMILIES[@]}"; do [[ "$existing" == "$family" ]] && return 0 done fi DEFERRED_CLEANUP_FAMILIES+=("$family") debug_log "Deferred cleanup while active: $family" } format_deferred_cleanup_families() { local family local output="" for family in "${DEFERRED_CLEANUP_FAMILIES[@]}"; do [[ -n "$output" ]] && output+=", " output+="$family" done printf '%s\n' "$output" } # shellcheck disable=SC2329 register_dry_run_cleanup_target() { # Full clean runs deduplicate the file-backed ledger in one linear pass. # Avoid an O(n²) Bash array scan while candidates are still being found. if [[ -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" && -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then return 0 fi local path="$1" local identity identity=$(mole_path_identity "$path") if [[ ${#DRY_RUN_SEEN_IDENTITIES[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${DRY_RUN_SEEN_IDENTITIES[@]}"; then return 1 fi DRY_RUN_SEEN_IDENTITIES+=("$identity") return 0 } # Append one candidate to the dry-run ledger. Final rendering deduplicates the # shared file, so timeout subprocess paths survive without an O(n²) hot loop. # Fields are NUL-delimited to preserve whitespace in paths. # Args: path, size_kb, item_count, size_known append_dry_run_cleanup_target() { local path="$1" local size_kb="${2:-0}" local item_count="${3:-1}" local size_known="${4:-true}" local identity identity=$(mole_path_identity "$path") [[ "$size_kb" =~ ^[0-9]+$ ]] || { size_kb=0 size_known=false } [[ "$item_count" =~ ^[0-9]+$ && "$item_count" -gt 0 ]] || item_count=1 [[ "$size_known" == "true" || "$size_known" == "false" ]] || size_known=false if [[ -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" && -f "$CLEAN_PREVIEW_LEDGER_FILE" && ! -L "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then printf '%s\0%s\0%s\0%s\0%s\0%s\0' \ "$identity" "$size_kb" "$item_count" "$size_known" "${CURRENT_SECTION:-Uncategorized}" "$path" \ >> "$CLEAN_PREVIEW_LEDGER_FILE" return 0 fi # Keep focused function tests and sourced-module callers useful even when # start_cleanup has not prepared the full ledger. if [[ -n "${EXPORT_LIST_FILE:-}" ]]; then ensure_user_file "$EXPORT_LIST_FILE" if [[ "$size_known" == "true" ]]; then echo "$path # $(bytes_to_human "$((size_kb * 1024))")" >> "$EXPORT_LIST_FILE" else echo "$path # size unknown" >> "$EXPORT_LIST_FILE" fi fi } # Validate and append a candidate in one call. Focused callers without a # prepared ledger retain the legacy in-memory duplicate check. record_dry_run_cleanup_target() { local path="$1" local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" if [[ "${MOLE_CURRENT_COMMAND:-}" == "clean" ]] && mole_rc_timeout_or_signal "$pending_clean_cancel"; then return "$pending_clean_cancel" fi if [[ "${_MOLE_DRY_RUN_TARGET_PREVALIDATED:-false}" != "true" ]]; then if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$path" 2> /dev/null; then return 1 fi if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$path" 2> /dev/null; then return 1 fi if declare -f holds_compiled_model_cache > /dev/null 2>&1 && holds_compiled_model_cache "$path" 2> /dev/null; then return 1 fi # Keep preview eligibility identical to real cleanup (#1390 / PR #1391). if declare -f _mole_should_refuse_live_user_cache_path > /dev/null 2>&1; then local live_cache_state=0 _mole_should_refuse_live_user_cache_path "$path" || live_cache_state=$? if [[ $live_cache_state -eq 0 || $live_cache_state -eq 2 ]]; then return 1 fi if mole_rc_timeout_or_signal "$live_cache_state"; then _mole_record_clean_cancellation "$live_cache_state" return "$live_cache_state" fi fi if declare -f _mole_is_sqlite_database_path > /dev/null 2>&1 && _mole_is_sqlite_database_path "$path" && declare -f _mole_sqlite_database_in_use > /dev/null 2>&1; then local sqlite_state=0 _mole_sqlite_database_in_use "$path" || sqlite_state=$? if [[ $sqlite_state -eq 0 || $sqlite_state -eq 2 ]]; then return 1 fi if mole_rc_timeout_or_signal "$sqlite_state"; then _mole_record_clean_cancellation "$sqlite_state" return "$sqlite_state" fi fi fi if [[ -z "${CLEAN_PREVIEW_LEDGER_FILE:-}" || ! -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then register_dry_run_cleanup_target "$path" || return 1 fi append_dry_run_cleanup_target "$@" } # Emit the first complete ledger record for each path identity, with a seventh # field naming the nearest measured ancestor that is also a candidate, or empty. # Sections overlap on purpose: "User essentials" sweeps ~/Library/Caches/* whole, # then later sections list ~/Library/Caches/Yarn/v6 or Homebrew/downloads/* # again. The preview measured both, so Potential space counted those bytes # twice; a real run only ever frees them once, whichever order the sections # reach them in. The row stays in the preview, because a whitelist entry for # the child is how a user protects it, but the renderer counts it under the # ancestor. An ancestor with unknown size covers nothing: its children's # measured bytes are the only figure the "N+" lower-bound total has for that tree. # Identity duplicates are dropped before coverage is decided, so a stale # unknown-size duplicate of an ancestor cannot hide a measured child. # Perl keeps the normal path linear for large clean previews; the Bash # fallback preserves the same NUL-safe format on systems without Perl. emit_deduplicated_dry_run_ledger() { if [[ -z "${CLEAN_PREVIEW_LEDGER_FILE:-}" || ! -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then return 0 fi local perl_bin="" perl_bin=$(command -v perl 2> /dev/null || true) if [[ -n "$perl_bin" && -x "$perl_bin" ]]; then # shellcheck disable=SC2016 # Embedded Perl uses Perl variables inside single quotes. "$perl_bin" -e ' use strict; use warnings; binmode STDIN; binmode STDOUT; local $/ = "\0"; my @records; my %seen; my %measured; RECORD: while (defined(my $identity = )) { chomp $identity; my @record = ($identity); for (1 .. 5) { my $field = ; last RECORD unless defined $field; chomp $field; push @record, $field; } next if $seen{$identity}++; push @records, \@record; my $path = $record[5]; $path =~ s{/+\z}{} if length($path) > 1; $measured{$path} = 1 if $record[3] eq "true"; } for my $record (@records) { my $ancestor = $record->[5]; $ancestor =~ s{/+\z}{} if length($ancestor) > 1; my $covered_by = ""; while ($ancestor =~ s{/[^/]*\z}{} && length $ancestor) { if ($measured{$ancestor}) { $covered_by = $ancestor; last; } } print join("\0", @$record, $covered_by), "\0"; } ' < "$CLEAN_PREVIEW_LEDGER_FILE" return 0 fi local identity size_kb count size_known section path local -a record_identities=() local -a record_sizes=() local -a record_counts=() local -a record_size_knowns=() local -a record_sections=() local -a record_paths=() local -a seen_identities=() local -a measured_paths=() # One joined string lets each identity or ancestor lookup be a single # pattern match instead of a scan over every entry seen so far. A # separator byte inside an entry would make that match ambiguous, so the # exact list scan is used instead in that case. local measured_separator=$'\x1f' local seen_joined="" local seen_joined_usable=true local measured_joined="" local measured_joined_usable=true local trimmed_path="" while IFS= read -r -d '' identity && IFS= read -r -d '' size_kb && IFS= read -r -d '' count && IFS= read -r -d '' size_known && IFS= read -r -d '' section && IFS= read -r -d '' path; do # The needle must be separator-free too: a value that happens to # contain the separator could match across two stored entries. if [[ "$seen_joined_usable" == "true" && "$identity" != *"$measured_separator"* ]]; then if [[ "$seen_joined" == *"$measured_separator$identity$measured_separator"* ]]; then continue fi elif [[ ${#seen_identities[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${seen_identities[@]}"; then continue fi seen_identities+=("$identity") [[ "$identity" == *"$measured_separator"* ]] && seen_joined_usable=false seen_joined+="$measured_separator$identity$measured_separator" record_identities+=("$identity") record_sizes+=("$size_kb") record_counts+=("$count") record_size_knowns+=("$size_known") record_sections+=("$section") record_paths+=("$path") if [[ "$size_known" == "true" ]]; then # Trim in place: a command substitution would drop a trailing # newline from the path and desynchronize the two engines. trimmed_path="$path" while [[ ${#trimmed_path} -gt 1 && "$trimmed_path" == */ ]]; do trimmed_path="${trimmed_path%/}" done measured_paths+=("$trimmed_path") [[ "$trimmed_path" == *"$measured_separator"* ]] && measured_joined_usable=false measured_joined+="$measured_separator$trimmed_path$measured_separator" fi done < "$CLEAN_PREVIEW_LEDGER_FILE" local record_index=0 local ancestor="" local covered_by="" while [[ $record_index -lt ${#record_identities[@]} ]]; do path="${record_paths[$record_index]}" ancestor="$path" while [[ ${#ancestor} -gt 1 && "$ancestor" == */ ]]; do ancestor="${ancestor%/}" done covered_by="" if [[ ${#measured_paths[@]} -gt 0 ]]; then while [[ "$ancestor" == */* ]]; do ancestor="${ancestor%/*}" [[ -n "$ancestor" ]] || break if [[ "$measured_joined_usable" == "true" && "$ancestor" != *"$measured_separator"* ]]; then if [[ "$measured_joined" == *"$measured_separator$ancestor$measured_separator"* ]]; then covered_by="$ancestor" break fi elif mole_identity_in_list "$ancestor" "${measured_paths[@]}"; then covered_by="$ancestor" break fi done fi printf '%s\0%s\0%s\0%s\0%s\0%s\0%s\0' \ "${record_identities[$record_index]}" "${record_sizes[$record_index]}" \ "${record_counts[$record_index]}" "${record_size_knowns[$record_index]}" \ "${record_sections[$record_index]}" "$path" "$covered_by" record_index=$((record_index + 1)) done } write_clean_preview_header() { cat > "$EXPORT_LIST_FILE" << EOF # Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') # # How to protect files: # 1. Run mo clean --whitelist and press Enter to save your selections # 2. Append paths below to ~/.config/mole/whitelist, one per line # A saved file replaces optional defaults; built-in safety protections still apply. # # Example: # /Users/*/Library/Caches/com.example.app # EOF } render_clean_preview_from_ledger() { write_clean_preview_header local identity size_kb count size_known section path covered_by local current_rendered_section="" local known_size_kb=0 local rendered_items=0 local rendered_categories=0 local unknown_size_count=0 local -a seen_sections=() if [[ -n "${CLEAN_PREVIEW_LEDGER_FILE:-}" && -f "$CLEAN_PREVIEW_LEDGER_FILE" ]]; then while IFS= read -r -d '' identity && IFS= read -r -d '' size_kb && IFS= read -r -d '' count && IFS= read -r -d '' size_known && IFS= read -r -d '' section && IFS= read -r -d '' path && IFS= read -r -d '' covered_by; do if [[ "$section" != "$current_rendered_section" ]]; then echo "" >> "$EXPORT_LIST_FILE" echo "=== $section ===" >> "$EXPORT_LIST_FILE" current_rendered_section="$section" if [[ ${#seen_sections[@]} -eq 0 ]] || ! mole_identity_in_list "$section" "${seen_sections[@]}"; then seen_sections+=("$section") rendered_categories=$((rendered_categories + 1)) fi fi [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 [[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]] || count=1 local item_note="" [[ "$count" -gt 1 ]] && item_note=", $count items" # A row inside another measured candidate stays visible, so the # user can still copy it into the whitelist, but its bytes and # items are already in the ancestor's row. if [[ -n "$covered_by" ]]; then item_note+=", counted under $covered_by" fi if [[ "$size_known" == "true" ]]; then echo "$path # $(bytes_to_human "$((size_kb * 1024))")$item_note" >> "$EXPORT_LIST_FILE" else echo "$path # size unknown$item_note" >> "$EXPORT_LIST_FILE" [[ -n "$covered_by" ]] || unknown_size_count=$((unknown_size_count + 1)) fi [[ -z "$covered_by" ]] || continue known_size_kb=$((known_size_kb + size_kb)) rendered_items=$((rendered_items + count)) done < <(emit_deduplicated_dry_run_ledger) fi total_size_cleaned=$known_size_kb files_cleaned=$rendered_items total_items=$rendered_categories if [[ "$unknown_size_count" -gt 0 ]]; then DRY_RUN_TOTAL_PARTIAL=true else DRY_RUN_TOTAL_PARTIAL=false fi } read_clean_sudo_choice() { local had_force_char=false local previous_force_char="${MOLE_READ_KEY_FORCE_CHAR:-}" if [[ ${MOLE_READ_KEY_FORCE_CHAR+x} ]]; then had_force_char=true fi export MOLE_READ_KEY_FORCE_CHAR=1 local choice choice=$(read_key) if [[ "$had_force_char" == "true" ]]; then export MOLE_READ_KEY_FORCE_CHAR="$previous_force_char" else unset MOLE_READ_KEY_FORCE_CHAR fi printf '%s\n' "$choice" } read_clean_sudo_password_remainder() { local __remainder_var="$1" local remainder="" if [[ -r /dev/tty ]]; then IFS= read -r -s remainder < /dev/tty || true else IFS= read -r -s remainder || true fi printf -v "$__remainder_var" '%s' "$remainder" } prompt_for_system_clean() { local prompt_attempt=0 while true; do echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " local choice choice=$(read_clean_sudo_choice) # ESC aborts, Space skips, Enter (or any typed key, e.g. someone who # starts typing their password) proceeds to authentication. if [[ "$choice" == "QUIT" ]]; then echo -e " ${GRAY}Canceled${NC}" exit 0 fi if [[ "$choice" == "SPACE" ]]; then echo -e " ${GRAY}Skipped${NC}" echo "" SYSTEM_CLEAN=false break elif [[ "$choice" == "ENTER" ]]; then printf "\r\033[K" # Clear the prompt line if ensure_sudo_session "System cleanup requires admin access"; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" echo "" else SYSTEM_CLEAN=false echo "" echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" fi break elif [[ "$choice" == CHAR:* ]]; then local typed_password="${choice#CHAR:}" local password_remainder="" read_clean_sudo_password_remainder password_remainder typed_password="${typed_password}${password_remainder}" printf "\r\033[K" # Clear the prompt line if ensure_sudo_session_with_password "$typed_password" "System cleanup requires admin access"; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" echo "" else SYSTEM_CLEAN=false echo "" echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" fi unset typed_password password_remainder break else prompt_attempt=$((prompt_attempt + 1)) drain_pending_input 0.05 if [[ $prompt_attempt -ge 2 ]]; then SYSTEM_CLEAN=false echo -e " ${GRAY}Skipped${NC}" echo "" break fi printf "\r\033[K" echo -e "${YELLOW}${ICON_WARNING}${NC} Press Enter to continue, or Space to skip" fi done } CLEANUP_DONE=false # shellcheck disable=SC2329 cleanup() { local signal="${1:-EXIT}" local exit_code="${2:-$?}" if [[ "$CLEANUP_DONE" == "true" ]]; then return 0 fi CLEANUP_DONE=true stop_inline_spinner 2> /dev/null || true # Background jobs ignore SIGINT. stop_project_cache_prefetch 2> /dev/null || true cleanup_temp_files stop_sudo_session show_cursor } trap 'cleanup EXIT $?' EXIT trap 'cleanup INT 130; exit 130' INT trap 'cleanup TERM 143; exit 143' TERM # IMPORTANT: This file overrides start_section / end_section from # lib/core/base.sh by virtue of being sourced after it. The clean variant adds # CURRENT_SECTION tracking, dry-run EXPORT_LIST_FILE writes, a section # spinner stop, and idle-header recycling. See the cross-reference block in # lib/core/base.sh and the differing purge variant in bin/purge.sh before # changing any of these three. # # Idle-header recycling: an idle section used to erase its own header after # the fact, which made all content below jump up two lines per idle section. # Instead the header stays put and the NEXT start_section overwrites it in # place, so the screen never moves vertically. Any output that is not a # section header must clear a leftover idle header first via # flush_idle_section_slot. IDLE_SECTION_PENDING=0 flush_idle_section_slot() { if [[ "${IDLE_SECTION_PENDING:-0}" == "1" ]]; then IDLE_SECTION_PENDING=0 safe_clear_lines 2 || true fi } start_section() { TRACK_SECTION=1 SECTION_ACTIVITY=0 CURRENT_SECTION="$1" if [[ "${IDLE_SECTION_PENDING:-0}" == "1" ]]; then # Overwrite the previous idle section's header line in place (the # pending flag is only ever set on an interactive ANSI terminal). IDLE_SECTION_PENDING=0 printf '\033[1A\r\033[2K%b\n' "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" else echo "" echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" fi if [[ "$DRY_RUN" == "true" && -z "${CLEAN_PREVIEW_LEDGER_FILE:-}" ]]; then ensure_user_file "$EXPORT_LIST_FILE" echo "" >> "$EXPORT_LIST_FILE" echo "=== $1 ===" >> "$EXPORT_LIST_FILE" fi } end_section() { stop_section_spinner if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then # On an interactive ANSI terminal, leave the header on screen and let # the next start_section recycle its line, so idle sections disappear # without the erase-and-jump. Piped output keeps the explicit fallback # so logs stay self-describing. MO_DEBUG interleaves stderr lines that # line recycling would corrupt, so keep the fallback there too. if [[ -t 1 && "${MO_DEBUG:-}" != "1" ]] && is_ansi_supported 2> /dev/null; then IDLE_SECTION_PENDING=1 else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to clean" fi else IDLE_SECTION_PENDING=0 fi TRACK_SECTION=0 } # shellcheck disable=SC2329 normalize_paths_for_cleanup() { local -a normalized_paths=() passthrough_paths=() local path normalized gradle_root="$HOME/.gradle/caches" for path in "$@"; do if [[ "$path" == *$'\n'* ]]; then passthrough_paths+=("$path") continue fi normalized="${path%/}" [[ -n "$normalized" ]] || normalized="$path" case "$normalized" in "$gradle_root"/*/groovy-dsl/*/* | "$gradle_root"/*/kotlin-dsl/*/*) local rel version rest dsl hash rel="${normalized#"$gradle_root"/}" version="${rel%%/*}" rest="${rel#*/}" dsl="${rest%%/*}" rest="${rest#*/}" hash="${rest%%/*}" if [[ -n "$version" && -n "$hash" && ("$dsl" == "groovy-dsl" || "$dsl" == "kotlin-dsl") ]]; then normalized="$gradle_root/$version/$dsl/$hash" fi ;; esac normalized_paths+=("$normalized") done local filtered="" filtered=$(mole_filter_nested_paths "${normalized_paths[@]+"${normalized_paths[@]}"}") || return $? while IFS= read -r path; do [[ -z "$path" ]] || printf '%s\0' "$path" done <<< "$filtered" if [[ ${#passthrough_paths[@]} -gt 0 ]]; then printf '%s\0' "${passthrough_paths[@]}" fi return 0 } # Measure reclaimable file blocks, not logical lengths. Each root is independent: # links outside that root and shared clone blocks stay out of its estimate. # This is measurement only; the normal deletion guards still own eligibility. _cleanup_path_sizes_kb() { # shellcheck disable=SC2016 # The single-quoted program contains Perl variables. run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" /usr/bin/perl -e ' use strict; use warnings; use File::Find; use Fcntl qw(:mode); sub private_bytes { my ($path, $allocated) = @_; # Darwin getattrlist: common FLAGS followed by extended PRIVATESIZE. # FSOPT_NOFOLLOW | FSOPT_ATTR_CMN_EXTENDED. Unsupported filesystems # retain allocated bytes; compressed resource forks do too. my $attrs = pack("SSLLLLL", 5, 0, 0x80040000, 0, 0, 0, 8); my $reply = "\0" x 36; my $name = "$path\0"; return $allocated if syscall(220, $name, $attrs, $reply, 36, 0x21) != 0; my ($length, $common, $volume, $dir, $file, $extended, $flags, $private) = unpack("L7Q", $reply); return $allocated if $length < 36 || !($common & 0x40000) || !($extended & 8) || ($flags & 0x20); return $private < $allocated ? $private : $allocated; } for my $root (@ARGV) { my @root_stat = lstat($root); if (!@root_stat || S_ISLNK($root_stat[2])) { print "0\n" or die $!; next; } my %links; my $bytes = 0; my $visit = sub { my $path = $_; my @s = lstat($path); return unless @s; if ($s[0] != $root_stat[0]) { $File::Find::prune = 1; return; } return unless S_ISREG($s[2]); my $allocated = $s[12] * 512; my $size = private_bytes($path, $allocated); if ($s[3] > 1) { my $key = "$s[0]:$s[1]"; $links{$key} ||= [0, $s[3], $size]; $links{$key}[0]++; } else { $bytes += $size; } }; if (S_ISDIR($root_stat[2])) { find({wanted => $visit, no_chdir => 1, follow => 0}, $root); } else { local $_ = $root; $visit->(); } for my $link (values %links) { $bytes += $link->[2] if $link->[0] == $link->[1]; } print(int(($bytes + 1023) / 1024), "\n") or die $!; } ' -- "$@" 2> /dev/null } # shellcheck disable=SC2329 get_cleanup_path_size_kb() { _cleanup_path_sizes_kb "$1" } # Classification helper for cleanup risk levels # shellcheck disable=SC2329 classify_cleanup_risk() { local description="$1" local path="${2:-}" # HIGH RISK: System files, preference files, require sudo if [[ "$description" =~ [Ss]ystem || "$description" =~ [Ss]udo || "$path" =~ ^/System || "$path" =~ ^/Library ]]; then echo "HIGH|System files or requires admin access" return fi # HIGH RISK: Preference files that might affect app functionality if [[ "$description" =~ [Pp]reference || "$path" =~ /Preferences/ ]]; then echo "HIGH|Preference files may affect app settings" return fi # MEDIUM RISK: Installers, large files, app bundles if [[ "$description" =~ [Ii]nstaller || "$description" =~ [Aa]pp.*[Bb]undle || "$description" =~ [Ll]arge ]]; then echo "MEDIUM|Installer packages or app data" return fi # MEDIUM RISK: Old backups, downloads if [[ "$description" =~ [Bb]ackup || "$description" =~ [Dd]ownload || "$description" =~ [Oo]rphan ]]; then echo "MEDIUM|Backup or downloaded files" return fi # LOW RISK: Caches, logs, temporary files (automatically regenerated) if [[ "$description" =~ [Cc]ache || "$description" =~ [Ll]og || "$description" =~ [Tt]emp || "$description" =~ [Tt]humbnail ]]; then echo "LOW|Cache/log files, automatically regenerated" return fi # DEFAULT: MEDIUM echo "MEDIUM|User data files" } # Internal implementation shared by the normal and process-guarded cleanup # entry points. The first argument is an optional callback that must return 0 # immediately before each deletion sink; callers use it to bind a process-state # check to the path that was just sized. A refusal stops the batch, except one # that sets _MOLE_SAFE_CLEAN_SKIP_PATH to the path it was handed: that target # alone is skipped and the rest continue. # shellcheck disable=SC2329 _safe_clean_impl() { local delete_guard="$1" shift local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" if [[ "${MOLE_CURRENT_COMMAND:-}" == "clean" ]] && mole_rc_timeout_or_signal "$pending_clean_cancel"; then return "$pending_clean_cancel" fi if _mole_clean_section_budget_spent; then return 0 fi if [[ $# -eq 0 ]]; then return 0 fi local sizing_timeouts_start="${MOLE_CLEAN_SIZING_TIMEOUTS:-0}" # Any size Mole could not read keeps the row: its 0B would be a guess. local sizing_unknown=false local description local -a targets if [[ $# -eq 1 ]]; then description="$1" targets=("$1") else description="${*: -1}" targets=("${@:1:$#-1}") fi local -a valid_targets=() for target in "${targets[@]}"; do # Missing targets cannot become less safe by being skipped. Filter them # before the protection, whitelist, and compiled-model probes below; # every target that still exists is fully checked again at the sink. # Preserve broken symlinks so the deletion policy can classify them. if [[ ! -e "$target" && ! -L "$target" ]]; then continue fi valid_targets+=("$target") done if [[ ${#valid_targets[@]} -gt 0 ]]; then targets=("${valid_targets[@]}") else targets=() fi if [[ ${#targets[@]} -eq 0 ]]; then return 0 fi local removed_any=0 local total_size_kb=0 local total_count=0 local skipped_count=0 local removal_failed_count=0 local delete_guard_stopped=0 local cleanup_interrupt_rc=0 local section_deadline="${_MOLE_CLEAN_SECTION_DEADLINE:-}" # A guarded cleanup may bind the exact object it approved to safe_remove's # final identity check. These names deliberately use dynamic scope so the # callback can populate them without stdout/command-substitution races. local _MOLE_SAFE_CLEAN_BOUND_PATH="" local _MOLE_SAFE_CLEAN_SKIP_PATH="" local _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" local _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" local _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" local permission_start=${MOLE_PERMISSION_DENIED_COUNT:-0} local show_scan_feedback=false if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then show_scan_feedback=true # Updates a running section spinner in place instead of restarting it. start_section_spinner "Scanning ${#targets[@]} items..." fi local _perf_scan_start debug_timer_start _perf_scan_start local -a existing_paths=() for path in "${targets[@]}"; do local skip=false if should_protect_path "$path"; then skip=true skipped_count=$((skipped_count + 1)) log_operation "clean" "SKIPPED" "$path" "protected" fi [[ "$skip" == "true" ]] && continue if is_path_whitelisted "$path"; then skip=true skipped_count=$((skipped_count + 1)) log_operation "clean" "SKIPPED" "$path" "whitelist" fi [[ "$skip" == "true" ]] && continue if holds_compiled_model_cache "$path"; then skip=true skipped_count=$((skipped_count + 1)) log_operation "clean" "SKIPPED" "$path" "compiled model cache" fi [[ "$skip" == "true" ]] && continue if [[ -e "$path" ]]; then existing_paths+=("$path") fi done if [[ ${#existing_paths[@]} -gt 1 ]]; then local -a normalized_paths=() while IFS= read -r -d '' path; do [[ -n "$path" ]] && normalized_paths+=("$path") done < <(normalize_paths_for_cleanup "${existing_paths[@]}") if [[ ${#normalized_paths[@]} -gt 0 ]]; then existing_paths=("${normalized_paths[@]}") else existing_paths=() fi fi debug_timer_end "$description: path scan" _perf_scan_start # Keep the spinner alive between phases; the next phase swaps its text in # place. Under MO_DEBUG stop it so debug lines print on a clean line. if [[ "$show_scan_feedback" == "true" && "${MO_DEBUG:-}" == "1" ]]; then stop_section_spinner fi debug_log "Cleaning: $description, ${#existing_paths[@]} items" # Enhanced debug output with risk level and details if [[ "${MO_DEBUG:-}" == "1" && ${#existing_paths[@]} -gt 0 ]]; then # Determine risk level for this cleanup operation local risk_info risk_info=$(classify_cleanup_risk "$description" "${existing_paths[0]}") local risk_level="${risk_info%%|*}" local risk_reason="${risk_info#*|}" debug_operation_start "$description" debug_risk_level "$risk_level" "$risk_reason" debug_operation_detail "Item count" "${#existing_paths[@]}" # Log sample of files (first 10) with details if [[ ${#existing_paths[@]} -le 10 ]]; then debug_operation_detail "Files to be removed" "All files listed below" else debug_operation_detail "Files to be removed" "Showing first 10 of ${#existing_paths[@]} files" fi fi if [[ $skipped_count -gt 0 ]]; then whitelist_skipped_count=$((whitelist_skipped_count + skipped_count)) fi if [[ ${#existing_paths[@]} -eq 0 ]]; then # The scan spinner we started (or took over) must not outlive this # call; callers print rows without stopping spinners themselves. if [[ "$show_scan_feedback" == "true" ]]; then stop_section_spinner fi [[ $delete_guard_stopped -eq 1 ]] && return 75 return 0 fi local show_spinner=false if [[ ${#existing_paths[@]} -gt 10 ]]; then show_spinner=true local total_paths=${#existing_paths[@]} if [[ -t 1 ]]; then start_section_spinner "Scanning ${description}..."; fi fi local cleaning_spinner_started=false local _perf_size_start debug_timer_start _perf_size_start # For larger batches, precompute sizes in parallel for better UX/stat accuracy. if [[ ${#existing_paths[@]} -gt 3 ]]; then local temp_dir temp_dir=$(create_temp_dir) local dir_count=0 local sample_size=$((${#existing_paths[@]} > 20 ? 20 : ${#existing_paths[@]})) local max_sample=$((${#existing_paths[@]} * 20 / 100)) [[ $max_sample -gt $sample_size ]] && sample_size=$max_sample for ((i = 0; i < sample_size && i < ${#existing_paths[@]}; i++)); do [[ -d "${existing_paths[i]}" ]] && ((dir_count++)) done # Mostly files share one measurement process instead of per-file subshells. if [[ $dir_count -lt 5 && ${#existing_paths[@]} -gt 20 ]]; then if [[ -t 1 && "$show_spinner" == "false" ]]; then start_section_spinner "Scanning ${description}..." show_spinner=true fi local idx=0 local _bytes local bulk_stat_file="$temp_dir/bulk_stat" local bulk_stat_rc=0 _cleanup_path_sizes_kb "${existing_paths[@]}" < /dev/null \ > "$bulk_stat_file" 2> /dev/null || bulk_stat_rc=$? if [[ $bulk_stat_rc -ge 128 ]]; then cleanup_interrupt_rc=$bulk_stat_rc elif mole_rc_timeout "$bulk_stat_rc"; then # The size is only used for the freed total; a stalled stat # must not cancel the delete set. Sizes are already 0 here. MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) fi if [[ $bulk_stat_rc -ne 0 ]] || ! awk -v expected="${#existing_paths[@]}" ' !/^[0-9]+$/ { invalid = 1 } END { exit invalid || NR != expected } ' "$bulk_stat_file"; then : > "$bulk_stat_file" sizing_unknown=true fi while IFS= read -r _bytes; do [[ "$_bytes" =~ ^[0-9]+$ ]] || _bytes=0 local _kb=$_bytes if [[ "$_kb" -gt 0 ]]; then echo "$_kb 1" > "$temp_dir/result_${idx}" else echo "0 0" > "$temp_dir/result_${idx}" fi idx=$((idx + 1)) done < "$bulk_stat_file" while [[ $idx -lt ${#existing_paths[@]} ]]; do echo "0 0" > "$temp_dir/result_${idx}" idx=$((idx + 1)) done else local -a pids=() local idx=0 local completed=0 local last_progress_update last_progress_update=$(get_epoch_seconds) local total_paths=${#existing_paths[@]} if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do ( local size=0 size_rc=0 local size_unknown=0 size=$(get_cleanup_path_size_kb "$path") || size_rc=$? if [[ $size_rc -ge 128 ]]; then exit "$size_rc" fi if mole_rc_timeout "$size_rc"; then # Sizing budget exhausted: keep the item in the # delete set and report its size as 0. size_unknown=1 elif [[ $size_rc -ne 0 || ! "$size" =~ ^[0-9]+$ ]]; then # Another sizing failure: still cleaned, size unknown. size_unknown=2 fi [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then echo "$size 1 $size_unknown" > "$tmp_file" else echo "0 0 $size_unknown" > "$tmp_file" fi mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true ) < /dev/null & pids+=($!) idx=$((idx + 1)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then local wait_rc=0 completed_pid="" slot mole_wait_for_any_worker completed_pid "${pids[@]}" || wait_rc=$? if [[ $wait_rc -ge 128 ]]; then cleanup_interrupt_rc=$wait_rc break fi for slot in "${!pids[@]}"; do if [[ "${pids[$slot]}" == "$completed_pid" ]]; then unset 'pids[slot]' break fi done completed=$((completed + 1)) if [[ "$show_spinner" == "true" && -t 1 ]]; then update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 "$description" || true fi fi done fi if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do local wait_rc=0 wait "$pid" 2> /dev/null || wait_rc=$? if [[ $wait_rc -ge 128 ]]; then [[ $cleanup_interrupt_rc -ne 0 ]] || cleanup_interrupt_rc=$wait_rc fi completed=$((completed + 1)) if [[ "$show_spinner" == "true" && -t 1 ]]; then update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 "$description" || true fi done fi fi # Count the items whose size check hit the budget; they were still # cleaned, only the freed total is under-reported. local _t_size=0 local _t_count=0 local _t_flag=0 local _t_file for _t_file in "$temp_dir"/result_*; do [[ -f "$_t_file" ]] || continue _t_flag=0 read -r _t_size _t_count _t_flag < "$_t_file" 2> /dev/null || true [[ "$_t_flag" == "1" ]] && MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) [[ "$_t_flag" == "2" ]] && sizing_unknown=true done if [[ $cleanup_interrupt_rc -ne 0 ]]; then if [[ "$show_spinner" == "true" || "$show_scan_feedback" == "true" ]]; then stop_inline_spinner fi MOLE_CLEAN_CANCEL_STATUS=$cleanup_interrupt_rc export MOLE_CLEAN_CANCEL_STATUS _mole_note_clean_cancel_source "$description" return "$cleanup_interrupt_rc" fi debug_timer_end "$description: size calc" _perf_size_start local _perf_del_start debug_timer_start _perf_del_start # Read results back in original order. # Start spinner for cleaning phase if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then start_section_spinner "Cleaning..." cleaning_spinner_started=true elif [[ -n "$delete_guard" && -t 1 ]]; then # The preview still asks the owner guard about every item, about # 70ms each, which left VS Code logs silent for 0.7s. start_section_spinner "Checking ${description}..." cleaning_spinner_started=true fi idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do if _mole_clean_section_budget_spent; then break fi local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then read -r size count size_unknown < "$result_file" 2> /dev/null || true local removed=0 local action_rc=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -n "$delete_guard" ]]; then _MOLE_SAFE_CLEAN_BOUND_PATH="" _MOLE_SAFE_CLEAN_SKIP_PATH="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" "$delete_guard" "$path" || action_rc=$? if mole_rc_timeout_or_signal "$action_rc"; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -ne 0 && "$_MOLE_SAFE_CLEAN_SKIP_PATH" == "$path" ]]; then idx=$((idx + 1)) continue elif [[ $action_rc -ne 0 ]]; then delete_guard_stopped=1 break fi fi action_rc=0 local bound_parent="" local bound_parent_id="" local bound_target_id="" if [[ "$_MOLE_SAFE_CLEAN_BOUND_PATH" == "$path" ]]; then bound_parent="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT" bound_parent_id="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID" bound_target_id="$_MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID" fi safe_remove "$path" true "$size" "$section_deadline" \ "$bound_parent" "$bound_parent_id" \ "$bound_target_id" || action_rc=$? # A removal timeout (124) is a failed removal, not a # user interrupt: count it below and keep cleaning so # one slow disk item never cancels the rest of the run. if [[ $action_rc -ge 128 ]]; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -eq 0 ]]; then removed=1 fi else if [[ -n "$delete_guard" ]]; then _MOLE_SAFE_CLEAN_BOUND_PATH="" _MOLE_SAFE_CLEAN_SKIP_PATH="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" "$delete_guard" "$path" || action_rc=$? if mole_rc_timeout_or_signal "$action_rc"; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -ne 0 && "$_MOLE_SAFE_CLEAN_SKIP_PATH" == "$path" ]]; then idx=$((idx + 1)) continue elif [[ $action_rc -ne 0 ]]; then delete_guard_stopped=1 break fi fi action_rc=0 record_dry_run_cleanup_target \ "$path" "$size" 1 true || action_rc=$? if mole_rc_timeout_or_signal "$action_rc"; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -eq 0 ]]; then removed=1 fi fi if [[ $removed -eq 1 ]]; then if [[ "$size" -gt 0 ]]; then total_size_kb=$((total_size_kb + size)) fi total_count=$((total_count + 1)) removed_any=1 else if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then removal_failed_count=$((removal_failed_count + 1)) fi fi fi idx=$((idx + 1)) done fi debug_timer_end "$description: deletion" _perf_del_start else debug_timer_end "$description: size calc" _perf_size_start local _perf_del_start debug_timer_start _perf_del_start # Start spinner for cleaning phase (small batch) if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then start_section_spinner "Cleaning..." cleaning_spinner_started=true fi local idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do if _mole_clean_section_budget_spent; then break fi local size_kb=0 local size_rc=0 size_kb=$(get_cleanup_path_size_kb "$path") || size_rc=$? if [[ $size_rc -ge 128 ]]; then cleanup_interrupt_rc=$size_rc break elif mole_rc_timeout "$size_rc"; then # Sizing budget exhausted: keep cleaning with size 0. MOLE_CLEAN_SIZING_TIMEOUTS=$((${MOLE_CLEAN_SIZING_TIMEOUTS:-0} + 1)) fi if [[ $size_rc -ne 0 || ! "$size_kb" =~ ^[0-9]+$ ]]; then sizing_unknown=true fi [[ ! "$size_kb" =~ ^[0-9]+$ ]] && size_kb=0 local removed=0 local action_rc=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -n "$delete_guard" ]]; then _MOLE_SAFE_CLEAN_BOUND_PATH="" _MOLE_SAFE_CLEAN_SKIP_PATH="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" "$delete_guard" "$path" || action_rc=$? if mole_rc_timeout_or_signal "$action_rc"; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -ne 0 && "$_MOLE_SAFE_CLEAN_SKIP_PATH" == "$path" ]]; then idx=$((idx + 1)) continue elif [[ $action_rc -ne 0 ]]; then delete_guard_stopped=1 break fi fi action_rc=0 local bound_parent="" local bound_parent_id="" local bound_target_id="" if [[ "$_MOLE_SAFE_CLEAN_BOUND_PATH" == "$path" ]]; then bound_parent="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT" bound_parent_id="$_MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID" bound_target_id="$_MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID" fi safe_remove "$path" true "$size_kb" "$section_deadline" \ "$bound_parent" "$bound_parent_id" \ "$bound_target_id" || action_rc=$? # Same non-fatal removal-timeout policy as the # parallel-result loop above. if [[ $action_rc -ge 128 ]]; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -eq 0 ]]; then removed=1 fi else if [[ -n "$delete_guard" ]]; then _MOLE_SAFE_CLEAN_BOUND_PATH="" _MOLE_SAFE_CLEAN_SKIP_PATH="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT="" _MOLE_SAFE_CLEAN_EXPECTED_PARENT_ID="" _MOLE_SAFE_CLEAN_EXPECTED_TARGET_ID="" "$delete_guard" "$path" || action_rc=$? if mole_rc_timeout_or_signal "$action_rc"; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -ne 0 && "$_MOLE_SAFE_CLEAN_SKIP_PATH" == "$path" ]]; then idx=$((idx + 1)) continue elif [[ $action_rc -ne 0 ]]; then delete_guard_stopped=1 break fi fi action_rc=0 record_dry_run_cleanup_target \ "$path" "$size_kb" 1 true || action_rc=$? if mole_rc_timeout_or_signal "$action_rc"; then cleanup_interrupt_rc=$action_rc break elif [[ $action_rc -eq 0 ]]; then removed=1 fi fi if [[ $removed -eq 1 ]]; then if [[ "$size_kb" -gt 0 ]]; then total_size_kb=$((total_size_kb + size_kb)) fi total_count=$((total_count + 1)) removed_any=1 else if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then removal_failed_count=$((removal_failed_count + 1)) fi fi idx=$((idx + 1)) done fi debug_timer_end "$description: deletion" _perf_del_start fi if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" || "$show_scan_feedback" == "true" ]]; then stop_inline_spinner fi if [[ $cleanup_interrupt_rc -ne 0 ]]; then MOLE_CLEAN_CANCEL_STATUS=$cleanup_interrupt_rc export MOLE_CLEAN_CANCEL_STATUS _mole_note_clean_cancel_source "$description" return "$cleanup_interrupt_rc" fi local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0} # Track permission failures in debug output (avoid noisy user warnings). if [[ $permission_end -gt $permission_start && $removed_any -eq 0 ]]; then debug_log "Permission denied while cleaning: $description" fi if [[ $removal_failed_count -gt 0 && "$DRY_RUN" != "true" ]]; then debug_log "Skipped $removal_failed_count items, permission denied, in use, or timed out, for: $description" fi # A row that measured 0B (empty folders, zero-length files) still removes # its items, but printing it reads as noise. Only a size every item # reported as 0 hides it; a timeout or failed measurement keeps the row. if [[ $removed_any -eq 1 && $total_size_kb -eq 0 && "$sizing_unknown" == "false" && "${MOLE_CLEAN_SIZING_TIMEOUTS:-0}" == "$sizing_timeouts_start" ]]; then stop_section_spinner mole_add_cleaned_row "$total_count" 0 elif [[ $removed_any -eq 1 ]]; then # Stop spinner before output stop_section_spinner local size_human size_human=$(bytes_to_human "$((total_size_kb * 1024))") # Multi-target cleanups report the item count as part of the detail # column, keeping the label clean: "npm logs · 2 items, 2KB". Use the # actually-cleaned count (total_count), not the raw target count, so it # stays consistent with the reported size after protected, whitelisted, # missing, and deduplicated targets have been dropped. local count_note="" if [[ $total_count -gt 1 ]]; then count_note="$total_count items, " fi if [[ "$DRY_RUN" == "true" ]]; then local size_display size_display=$(colorize_human_size "$size_human") echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description${NC} · ${count_note}${size_display} ${YELLOW}dry${NC}" else local line_color line_color=$(cleanup_result_color_kb "$total_size_kb") echo -e " ${line_color}${ICON_SUCCESS}${NC} $description${NC} · ${count_note}$(colorize_human_size "${size_human}")" fi mole_add_cleaned_row "$total_count" "$total_size_kb" note_activity fi # 75 is internal to safe_clean_guarded. Normal safe_clean calls never set a # guard and retain the existing always-zero cleanup contract. [[ $delete_guard_stopped -eq 1 ]] && return 75 return 0 } # shellcheck disable=SC2329 safe_clean() { _safe_clean_impl "" "$@" } # Run safe_clean with a callback rechecked after sizing and immediately before # every safe_remove call. Returns 75 when the callback stops the batch after # preserving any removals already completed. # shellcheck disable=SC2329 safe_clean_guarded() { local delete_guard="$1" shift declare -f "$delete_guard" > /dev/null 2>&1 || return 2 _safe_clean_impl "$delete_guard" "$@" } # Prints the refusal behind an error line, or fails when the line is anything # else. Only the text after the last ": " counts, since mktemp quotes the path. _clean_write_refusal() { local message="${1##*: }" case "$message" in "Operation not permitted" | "Permission denied" | "Read-only file system") printf '%s' "$message" return 0 ;; esac return 1 } # Creates and removes one empty folder in $1. Prints the reason and succeeds only # when the system refused the write. _clean_write_check() { local output="" path="" if ! output=$(LC_ALL=C mktemp -d "$1/.mole-write-check.XXXXXX" 2>&1); then _clean_write_refusal "${output##*$'\n'}" return fi # Stderr noise from a healthy mktemp comes first; the path is the last line. path="${output##*$'\n'}" if output=$(LC_ALL=C rmdir "$path" 2>&1); then return 1 fi _clean_write_refusal "${output##*$'\n'}" } # A shell that may not write here, such as an agent's sandbox, has every delete # denied, and a denied delete reads as nothing to clean: the run used to end on # "Cleanup complete" and exit 0 with nothing removed. Only an explicit refusal # stops the run. Any other failure carries on, above all ENOSPC, since a full # disk is what people run this for. A home run is stopped only when every folder # it checks refuses, so one odd folder never costs the rest of the cleanup. refuse_cleanup_without_write_access() { [[ "$DRY_RUN" == "true" ]] && return 0 local -a targets=() if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then targets=("$EXTERNAL_VOLUME_TARGET") else targets=("$HOME/Library/Caches" "$HOME/Library/Logs") fi local target="" reason="" refused_target="" refused_reason="" for target in "${targets[@]}"; do [[ -d "$target" ]] || continue reason=$(_clean_write_check "$target") || return 0 if [[ -z "$refused_target" ]]; then refused_target="$target" refused_reason="$reason" fi done [[ -n "$refused_target" ]] || return 0 printf 'mo clean cannot remove files from this shell: writing to %s was refused (%s).\n' "${refused_target/#$HOME/~}" "$refused_reason" >&2 printf 'Nothing was removed.' >&2 [[ "$refused_reason" == "Read-only file system" ]] || printf ' If this shell runs in a sandbox, run mo clean in your own terminal.' >&2 printf '\n' >&2 return 1 } start_cleanup() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="clean" MOLE_CLEAN_CANCEL_STATUS=0 export MOLE_CLEAN_CANCEL_STATUS MOLE_CLEAN_CANCEL_SOURCE="" export MOLE_CLEAN_CANCEL_SOURCE MOLE_CLEAN_SIZING_TIMEOUTS=0 export MOLE_CLEAN_SIZING_TIMEOUTS MOLE_CLEAN_REMOVAL_TIMEOUTS=0 export MOLE_CLEAN_REMOVAL_TIMEOUTS MOLE_CLEAN_REMOVAL_TIMEOUT_PATHS="" export MOLE_CLEAN_REMOVAL_TIMEOUT_PATHS _MOLE_CLEAN_SECTION_DEADLINE="" log_operation_session_start "clean" DRY_RUN_SEEN_IDENTITIES=() DRY_RUN_TOTAL_PARTIAL=false if [[ -t 1 ]]; then printf '\033[2J\033[H' fi printf '\n' # Both system and external-volume previews publish the same candidate ledger. if [[ "$DRY_RUN" == "true" ]]; then prepare_clean_preview_file || { echo -e "${YELLOW}${ICON_WARNING}${NC} Unable to create a safe cleanup preview file" >&2 return 1 } write_clean_preview_header fi if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then echo -e "${PURPLE_BOLD}Clean External Volume${NC}" echo -e "${GRAY}${EXTERNAL_VOLUME_TARGET}${NC}" echo "" if [[ "$DRY_RUN" == "true" ]]; then echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" fi SYSTEM_CLEAN=false return 0 fi echo -e "${PURPLE_BOLD}Clean Your Mac${NC}" echo "" if [[ "$DRY_RUN" != "true" && -t 0 ]]; then echo -e "${GRAY}${ICON_WARNING} Use --dry-run to preview, --whitelist to manage protected paths${NC}" fi if [[ "$DRY_RUN" == "true" ]]; then echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" # Preview system section when sudo is already cached (no password prompt). if adopt_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included" echo "" else SYSTEM_CLEAN=false echo -e "${GRAY}${ICON_WARNING} System caches need sudo, run ${NC}sudo -v && mo clean --dry-run${GRAY} for full preview${NC}" echo "" fi return fi if [[ -t 0 ]]; then if adopt_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" else prompt_for_system_clean fi else echo "" echo "Running in non-interactive mode" if adopt_sudo_session; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else SYSTEM_CLEAN=false echo " ${ICON_LIST} System-level cleanup skipped, requires sudo" fi echo " ${ICON_LIST} User-level cleanup will proceed automatically" echo "" fi } perform_cleanup() { if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then total_items=0 files_cleaned=0 total_size_cleaned=0 fi local initial_free_space_kb="" local initial_free_space_display="Unknown" local free_space_target="$EXTERNAL_VOLUME_TARGET" local free_space_parent="" free_space_parent_id="" free_space_volume_id="" if [[ -n "$free_space_target" ]] && _mole_snapshot_path_identity "$free_space_target"; then free_space_parent="$_MOLE_PATH_SNAPSHOT_PARENT" free_space_parent_id="$_MOLE_PATH_SNAPSHOT_PARENT_ID" free_space_volume_id="$_MOLE_PATH_SNAPSHOT_TARGET_ID" fi # Test mode skips expensive scans and returns minimal output. local test_mode_enabled=false if [[ -z "$EXTERNAL_VOLUME_TARGET" && "${MOLE_TEST_MODE:-0}" == "1" ]]; then test_mode_enabled=true if [[ "$DRY_RUN" == "true" ]]; then echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" fi echo -e "${GREEN}${ICON_LIST}${NC} User app cache" if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then local -a expanded_defaults expanded_defaults=() for default in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do expanded_defaults+=("${default/#\~/$HOME}") done local has_custom=false for pattern in "${WHITELIST_PATTERNS[@]}"; do local is_default=false local normalized_pattern="${pattern%/}" for default in "${expanded_defaults[@]}"; do local normalized_default="${default%/}" [[ "$normalized_pattern" == "$normalized_default" ]] && is_default=true && break done [[ "$is_default" == "false" ]] && has_custom=true && break done [[ "$has_custom" == "true" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} Protected items found" fi if [[ "$DRY_RUN" == "true" ]]; then echo "" echo -e "Potential space: $(colorize_human_size "0.00GB")" fi total_items=1 files_cleaned=0 total_size_cleaned=0 fi if [[ "$test_mode_enabled" == "false" && -z "$EXTERNAL_VOLUME_TARGET" ]]; then if ! initial_free_space_kb=$(get_free_space_kb 2> /dev/null); then initial_free_space_kb="" fi initial_free_space_display=$(format_free_space_kb "$initial_free_space_kb") echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $initial_free_space_display" fi if [[ "$test_mode_enabled" == "true" ]]; then local summary_heading="Test mode complete" local -a summary_details summary_details=() summary_details+=("Test mode - no actual cleanup performed") print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' return 0 fi # Pre-check TCC permissions to avoid mid-run prompts. if [[ -z "$EXTERNAL_VOLUME_TARGET" ]]; then check_tcc_permissions start_legacy_analyzer_cache_sweep fi if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then local predefined_count=0 local custom_count=0 for pattern in "${WHITELIST_PATTERNS[@]}"; do local is_predefined=false # Hard safety entries are the most core protection Mole ships, so # count them with the defaults. Attributing them to the user reads # as "you added these" for rules nobody opted into. for default in "${DEFAULT_WHITELIST_PATTERNS[@]}" "${SAFETY_WHITELIST_PATTERNS[@]}"; do local expanded_default="${default/#\~/$HOME}" if [[ "$pattern" == "$expanded_default" ]]; then is_predefined=true break fi done if [[ "$is_predefined" == "true" ]]; then predefined_count=$((predefined_count + 1)) else custom_count=$((custom_count + 1)) fi done if [[ $custom_count -gt 0 || $predefined_count -gt 0 ]]; then local summary="" [[ $predefined_count -gt 0 ]] && summary+="$predefined_count core" [[ $custom_count -gt 0 && $predefined_count -gt 0 ]] && summary+=" + " [[ $custom_count -gt 0 ]] && summary+="$custom_count custom" summary+=" patterns active" # One line: listing every pattern on each preview pushed the # cleanup rows a screen down. mo clean --whitelist shows them. echo -e "${BLUE}${ICON_ADMIN}${NC} Whitelist: $summary" fi fi if [[ -t 1 && "$DRY_RUN" != "true" ]]; then local fda_status=0 has_full_disk_access || fda_status=$? if [[ $fda_status -eq 1 ]]; then echo "" echo -e "${GRAY}${ICON_REVIEW}${NC} ${GRAY}Grant Full Disk Access to your terminal in System Settings for best results${NC}" fi fi total_items=0 files_cleaned=0 total_size_cleaned=0 DEFERRED_CLEANUP_FAMILIES=() local had_errexit=0 [[ $- == *e* ]] && had_errexit=1 # Allow per-section failures without aborting the full run. set +e _run_cleanup_step() { local required=false if [[ "${1:-}" == "--required" ]]; then required=true shift fi local pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" if mole_rc_timeout_or_signal "$pending_clean_cancel"; then return "$pending_clean_cancel" fi local step_name="${1:-cleanup step}" local _perf_step_start debug_timer_start _perf_step_start local step_rc=0 "$@" || step_rc=$? debug_timer_end "cleanup step: $step_name" _perf_step_start pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" if mole_rc_timeout_or_signal "$step_rc"; then MOLE_CLEAN_CANCEL_STATUS=$step_rc export MOLE_CLEAN_CANCEL_STATUS _mole_note_clean_cancel_source "$step_name" return "$step_rc" fi if mole_rc_timeout_or_signal "$pending_clean_cancel"; then _mole_note_clean_cancel_source "$step_name" return "$pending_clean_cancel" fi if [[ "$required" == "true" && $step_rc -ne 0 ]]; then return "$step_rc" fi return 0 } local cleanup_cancel_rc=0 # Sections run inside a function so a cancelled step (timeout/signal, # exit 124+) stops the remaining sections but still falls through to the # final summary instead of returning from perform_cleanup with no output. run_clean_sections() { if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then start_section "External volume" _run_cleanup_step --required \ clean_external_volume_target "$EXTERNAL_VOLUME_TARGET" || return $? end_section else start_project_cache_prefetch # ===== 1. System ===== if [[ "$SYSTEM_CLEAN" == "true" ]]; then start_section "System" _run_cleanup_step clean_deep_system || return $? _run_cleanup_step clean_local_snapshots || return $? end_section fi if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then flush_idle_section_slot echo "" for warning in "${WHITELIST_WARNINGS[@]}"; do echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning" done fi # ===== 2. User essentials ===== start_section "User essentials" _run_cleanup_step clean_user_essentials || return $? _run_cleanup_step clean_finder_metadata || return $? end_section # ===== 3. App caches (merged sandboxed and standard app caches) ===== start_section "App caches" _run_cleanup_step clean_app_caches || return $? end_section # ===== 4. Browsers ===== start_section "Browsers" _run_cleanup_step clean_browsers || return $? end_section # ===== 5. Cloud & Office ===== start_section "Cloud & Office" local _perf_cloud_office_start debug_timer_start _perf_cloud_office_start local cloud_office_rc=0 _run_cleanup_step run_cloud_and_office_cleanup || cloud_office_rc=$? debug_timer_end "cleanup step: run_cloud_and_office_cleanup" \ _perf_cloud_office_start if [[ $cloud_office_rc -ne 0 ]]; then if mole_rc_timeout_or_signal "$cloud_office_rc"; then _mole_record_clean_cancellation "$cloud_office_rc" return "$cloud_office_rc" else log_warning "Cloud & Office cleanup failed with exit code $cloud_office_rc" fi fi end_section # ===== 6. Developer tools (merged CLI and GUI tooling) ===== start_section "Developer tools" _run_cleanup_step clean_developer_tools || return $? end_section # ===== 7. Apps & utilities ===== start_section "Apps & utilities" _run_cleanup_step clean_user_gui_applications || return $? end_section # ===== 8. Virtualization ===== start_section "Virtualization" _run_cleanup_step clean_virtualization_tools || return $? end_section # ===== 9. Application Support ===== start_section "Application Support" _run_cleanup_step clean_application_support_logs || return $? end_section # ===== 10. App leftovers ===== start_section "App leftovers" _run_cleanup_step clean_orphaned_app_data || return $? _run_cleanup_step clean_orphaned_system_services || return $? # No stale-LaunchServices step here. An earlier note said # `lsregister -u` fails with -10814 for an app that is already # gone; on macOS 27.0.1 (2026-10-27) it exits 0 and the record # leaves the dump, so only the product decision keeps this out. # `mo optimize` no longer rebuilds LaunchServices either: the # rebuild made a running VPN extension read as reinstalled. _run_cleanup_step clean_orphaned_container_stubs || return $? _run_cleanup_step show_user_launch_agent_hint_notice || return $? end_section # ===== 11. Apple Silicon ===== _run_cleanup_step clean_apple_silicon_caches || return $? # ===== 12. Device backups & firmware ===== # iOS backups are reported once, in the Large files section; a second # row here used a different size formatter and confused users. start_section "Device backups & firmware" _run_cleanup_step clean_cached_device_firmware || return $? end_section # ===== 13. Time Machine ===== start_section "Time Machine" _run_cleanup_step clean_time_machine_failed_backups || return $? end_section # ===== 14. Large files ===== start_section "Large files" _run_cleanup_step check_large_file_candidates || return $? end_section # ===== 15. Project artifacts ===== start_section "Project artifacts" _run_cleanup_step show_project_artifact_hint_notice || return $? end_section fi } run_clean_sections || cleanup_cancel_rc=$? stop_project_cache_prefetch # ===== Final summary ===== flush_idle_section_slot echo "" if [[ "$DRY_RUN" == "true" ]]; then # Writing the detailed list took 0.8s for 267 items with nothing on screen. if [[ -t 1 ]]; then start_section_spinner "Preparing summary..."; fi render_clean_preview_from_ledger stop_section_spinner fi local summary_heading="" local summary_status="success" if mole_rc_timeout "$cleanup_cancel_rc"; then if [[ "$DRY_RUN" == "true" ]]; then summary_heading="Dry run cancelled" else summary_heading="Cleanup cancelled" fi summary_status="warning" elif [[ $cleanup_cancel_rc -ge 128 ]]; then if [[ "$DRY_RUN" == "true" ]]; then summary_heading="Dry run interrupted" else summary_heading="Cleanup interrupted" fi summary_status="warning" elif [[ $cleanup_cancel_rc -ne 0 ]]; then if [[ "$DRY_RUN" == "true" ]]; then summary_heading="Dry run incomplete" else summary_heading="Cleanup incomplete" fi summary_status="warning" elif [[ "$DRY_RUN" == "true" ]]; then summary_heading="Dry run complete - no changes made" else summary_heading="Cleanup complete" fi local -a summary_details=() if [[ $cleanup_cancel_rc -ne 0 ]]; then local cancel_source="" cancel_source=$(mole_terminal_safe_text "${MOLE_CLEAN_CANCEL_SOURCE:-}") local cancel_reason="" if mole_rc_timeout "$cleanup_cancel_rc"; then if [[ -n "$cancel_source" ]]; then cancel_reason="Cancelled: $cancel_source timed out (exit $cleanup_cancel_rc)." else cancel_reason="Cancelled: a scan or size check timed out (exit $cleanup_cancel_rc)." fi elif [[ $cleanup_cancel_rc -ge 128 ]]; then if [[ -n "$cancel_source" ]]; then cancel_reason="Cancelled: $cancel_source was interrupted (exit $cleanup_cancel_rc)." else cancel_reason="Cancelled: a cleanup step was interrupted (exit $cleanup_cancel_rc)." fi else cancel_reason="A required cleanup step failed (exit $cleanup_cancel_rc)." fi summary_details+=("${GRAY}${ICON_WARNING}${NC} $cancel_reason Remaining cleanup was skipped.") log_warning_to_file "$cancel_reason Section: ${CURRENT_SECTION:-unknown}." fi # Emit one "Free space" line, with the measured delta in parentheses when # available. $1 is the initial free space in KB; $2 is the selected volume. # Caller appends each printed line to summary_details. emit_free_space_summary() { local initial_kb="$1" local target="$2" # A disconnected mount may leave a directory on the internal disk. # Refuse it (or a replacement volume) instead of reporting that disk. if [[ -n "$target" ]] && ! _mole_path_matches_identity \ "$target" "$free_space_parent" "$free_space_parent_id" "$free_space_volume_id"; then printf 'Free space: Unknown\n' return 0 fi local final_kb if ! final_kb=$(get_free_space_kb "$target" 2> /dev/null); then final_kb="" fi if [[ -n "$target" ]] && ! _mole_path_matches_identity \ "$target" "$free_space_parent" "$free_space_parent_id" "$free_space_volume_id"; then final_kb="" fi local delta_note="" if [[ "$DRY_RUN" != "true" && "$initial_kb" =~ ^[0-9]+$ && "$final_kb" =~ ^[0-9]+$ && "$initial_kb" -ne "$final_kb" ]]; then delta_note=" ($(format_free_space_delta_kb "$((final_kb - initial_kb))"))" fi printf 'Free space: %s%s\n' "$(format_free_space_kb "$final_kb")" "$delta_note" } if [[ $total_size_cleaned -gt 0 || $files_cleaned -gt 0 || ("$DRY_RUN" == "true" && "$DRY_RUN_TOTAL_PARTIAL" == "true") ]]; then local freed_size_human freed_size_human=$(bytes_to_human_kb "$total_size_cleaned") if [[ "$DRY_RUN" == "true" ]]; then local potential_label if [[ "$DRY_RUN_TOTAL_PARTIAL" == "true" ]]; then potential_label="${freed_size_human}+" else potential_label="$freed_size_human" fi local stats="Potential space: $(colorize_human_size "$potential_label")" [[ $files_cleaned -gt 0 ]] && stats+=" · $(mole_count_label "$files_cleaned" item)" summary_details+=("$stats") { echo "" echo "# ============================================" echo "# Summary" echo "# ============================================" echo "# Potential cleanup: ${potential_label}" echo "# Items: $files_cleaned" echo "# Categories: $total_items" } >> "$EXPORT_LIST_FILE" else if [[ ${MOLE_CLEAN_SIZING_TIMEOUTS:-0} -gt 0 ]]; then if [[ $total_size_cleaned -gt 0 ]]; then freed_size_human="${freed_size_human}+" else freed_size_human="Partially measured" fi fi # The free-space line below carries the measured disk change, so # this line stays the bytes Mole itself removed (#1023). local summary_line="Removed: $(colorize_human_size "$freed_size_human")" if [[ $files_cleaned -gt 0 ]]; then summary_line+=" · $(mole_count_label "$files_cleaned" item)" fi summary_details+=("$summary_line") local free_space_line while IFS= read -r free_space_line; do summary_details+=("$free_space_line") done < <(emit_free_space_summary "$initial_free_space_kb" "$free_space_target") fi else if [[ $cleanup_cancel_rc -eq 0 ]]; then summary_status="info" if [[ ${#DEFERRED_CLEANUP_FAMILIES[@]} -gt 0 || ${MOLE_CLEAN_REMOVAL_TIMEOUTS:-0} -gt 0 ]]; then if [[ "$DRY_RUN" == "true" ]]; then summary_details+=("No additional reclaimable space detected.") else summary_details+=("No additional space freed.") fi elif [[ "$DRY_RUN" == "true" ]]; then summary_details+=("No significant reclaimable space detected, system already clean.") else summary_details+=("System was already clean; no additional space freed.") fi fi local free_space_line while IFS= read -r free_space_line; do summary_details+=("$free_space_line") done < <(emit_free_space_summary "$initial_free_space_kb" "$free_space_target") fi # Caches of running apps are deferred silently: skipping them is Mole's # ordinary behavior, not news, and for always-on tools (Codex, browsers) # a summary line here appeared on every single run. Preview still lists # what would be cleaned, and the ledger stays visible under --debug. if [[ ${#DEFERRED_CLEANUP_FAMILIES[@]} -gt 0 ]]; then debug_log "Deferred while active: $(format_deferred_cleanup_families)" fi if [[ "$DRY_RUN" == "true" && ($total_size_cleaned -gt 0 || "$DRY_RUN_TOTAL_PARTIAL" == "true" || $files_cleaned -gt 0) ]]; then if publish_clean_preview_file; then summary_details+=("Detailed file list: ${GRAY}$CLEAN_PREVIEW_FINAL_FILE${NC}") else summary_details+=("Cleanup preview file could not be written safely") fi elif [[ "$DRY_RUN" == "true" ]]; then publish_clean_preview_file || true fi if [[ ${MOLE_CLEAN_SIZING_TIMEOUTS:-0} -gt 0 ]]; then debug_log "Some items exceeded the ${MOLE_TIMEOUT_DISK_VERIFY_SEC}s size-check budget; the tracked total is partially measured." fi if [[ ${MOLE_CLEAN_REMOVAL_TIMEOUTS:-0} -gt 0 && "${MO_DEBUG:-}" == "1" ]]; then # Per-item timeouts belong in diagnostics; the operation log retains # every failed path. Keep the debug preview bounded too. local removal_timeout_note="item(s) exceeded the ${MOLE_TIMEOUT_DISK_VERIFY_SEC}s removal budget and may be only partly removed" local -a removal_timeout_paths=() local removal_timeout_path while IFS= read -r removal_timeout_path; do [[ -n "$removal_timeout_path" ]] && removal_timeout_paths+=("$removal_timeout_path") done <<< "${MOLE_CLEAN_REMOVAL_TIMEOUT_PATHS:-}" if [[ ${#removal_timeout_paths[@]} -gt 0 ]]; then local removal_timeout_show=3 [[ ${#removal_timeout_paths[@]} -lt $removal_timeout_show ]] && removal_timeout_show=${#removal_timeout_paths[@]} local removal_timeout_list="" local removal_timeout_idx local removal_timeout_display for ((removal_timeout_idx = 0; removal_timeout_idx < removal_timeout_show; removal_timeout_idx++)); do # Abbreviate $HOME: three absolute paths under # ~/Library/Developer already run past one terminal line, which # is the width this note is capped to keep. removal_timeout_display="${removal_timeout_paths[$removal_timeout_idx]}" [[ -n "$HOME" && "$removal_timeout_display" == "$HOME"/* ]] && removal_timeout_display="~${removal_timeout_display#"$HOME"}" removal_timeout_list+="${removal_timeout_list:+, }${removal_timeout_display}" done if [[ ${#removal_timeout_paths[@]} -gt $removal_timeout_show ]]; then removal_timeout_list+=", +$((${#removal_timeout_paths[@]} - removal_timeout_show)) more" fi removal_timeout_note+=": ${removal_timeout_list}" fi debug_log "${MOLE_CLEAN_REMOVAL_TIMEOUTS} ${removal_timeout_note}." fi if [[ $had_errexit -eq 1 ]]; then set -e fi # Log session end with summary log_operation_session_end "clean" "$files_cleaned" "$total_size_cleaned" print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' return "$cleanup_cancel_rc" } run_cloud_and_office_cleanup() { local cleanup_rc=0 local pending_clean_cancel=0 _MOLE_CLEAN_SECTION_DEADLINE=$((SECONDS + MOLE_CLOUD_OFFICE_SECTION_BUDGET_SEC)) clean_cloud_storage || cleanup_rc=$? pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" if mole_rc_timeout_or_signal "$cleanup_rc" || mole_rc_timeout_or_signal "$pending_clean_cancel"; then _MOLE_CLEAN_SECTION_DEADLINE="" if mole_rc_timeout_or_signal "$cleanup_rc"; then return "$cleanup_rc" fi return "$pending_clean_cancel" fi if ! _mole_clean_section_budget_spent; then cleanup_rc=0 clean_office_applications || cleanup_rc=$? pending_clean_cancel="${MOLE_CLEAN_CANCEL_STATUS:-0}" if mole_rc_timeout_or_signal "$cleanup_rc" || mole_rc_timeout_or_signal "$pending_clean_cancel"; then _MOLE_CLEAN_SECTION_DEADLINE="" if mole_rc_timeout_or_signal "$cleanup_rc"; then return "$cleanup_rc" fi return "$pending_clean_cancel" fi fi if _mole_clean_section_budget_spent; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Cloud & Office · ${GRAY}time limit reached, skipped remaining items${NC}" note_activity fi _MOLE_CLEAN_SECTION_DEADLINE="" return 0 } main() { while [[ $# -gt 0 ]]; do case "$1" in "--help" | "-h") show_clean_help exit 0 ;; "--debug") export MO_DEBUG=1 ;; "--dry-run" | "-n") DRY_RUN=true export MOLE_DRY_RUN=1 ;; "--external") shift if [[ $# -eq 0 ]]; then echo "Missing path for --external" >&2 exit 1 fi EXTERNAL_VOLUME_TARGET=$(validate_external_volume_target "$1") || exit 1 ;; "--whitelist") source "$SCRIPT_DIR/../lib/manage/whitelist.sh" manage_whitelist "clean" exit 0 ;; "--select" | "--categories" | "--exclude") echo "mo clean $1 was removed in this release." >&2 echo "Use 'mo clean --dry-run' to preview cleanup and 'mo clean --whitelist' to protect paths." >&2 exit 1 ;; -*) echo "Unknown option for mo clean: $1" >&2 echo "Run 'mo clean --help' for usage." >&2 exit 1 ;; *) echo "Unexpected argument for mo clean: $1" >&2 echo "Run 'mo clean --help' for usage." >&2 exit 1 ;; esac shift done refuse_cleanup_without_write_access || exit 1 start_cleanup hide_cursor local cleanup_rc=0 perform_cleanup || cleanup_rc=$? show_cursor exit "$cleanup_rc" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi