diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index f7b4e1c..72892ce 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -1,6 +1,9 @@ #!/bin/bash # Cache Cleanup Module set -euo pipefail + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/purge_shared.sh" # Preflight TCC prompts once to avoid mid-run interruptions. check_tcc_permissions() { [[ -t 1 ]] || return 0 @@ -88,154 +91,138 @@ clean_service_worker_cache() { fi fi } -# Next.js/Python project caches with tight scan bounds and timeouts. -clean_project_caches() { - stop_inline_spinner 2> /dev/null || true - # Fast pre-check before scanning the whole home dir. - local has_dev_projects=false - local -a common_dev_dirs=( - "$HOME/Code" - "$HOME/Projects" - "$HOME/workspace" - "$HOME/github" - "$HOME/dev" - "$HOME/work" - "$HOME/src" - "$HOME/repos" - "$HOME/Developer" - "$HOME/Development" - "$HOME/www" - "$HOME/golang" - "$HOME/go" - "$HOME/rust" - "$HOME/python" - "$HOME/ruby" - "$HOME/java" - "$HOME/dotnet" - "$HOME/node" - ) - for dir in "${common_dev_dirs[@]}"; do - if [[ -d "$dir" ]]; then - has_dev_projects=true - break +# Check whether a directory looks like a project container. +project_cache_has_indicators() { + local dir="$1" + local max_depth="${2:-5}" + local indicator_timeout="${MOLE_PROJECT_CACHE_DISCOVERY_TIMEOUT:-2}" + [[ -d "$dir" ]] || return 1 + + local -a find_args=("$dir" "-maxdepth" "$max_depth" "(") + local first=true + local indicator + for indicator in "${MOLE_PURGE_PROJECT_INDICATORS[@]}"; do + if [[ "$first" == "true" ]]; then + first=false + else + find_args+=("-o") + fi + find_args+=("-name" "$indicator") + done + find_args+=(")" "-print" "-quit") + + run_with_timeout "$indicator_timeout" find "${find_args[@]}" 2> /dev/null | grep -q . +} + +# Discover candidate project roots without scanning the whole home directory. +discover_project_cache_roots() { + local -a roots=() + local root + + for root in "${MOLE_PURGE_DEFAULT_SEARCH_PATHS[@]}"; do + [[ -d "$root" ]] && roots+=("$root") + done + + while IFS= read -r root; do + [[ -d "$root" ]] && roots+=("$root") + done < <(mole_purge_read_paths_config "$HOME/.config/mole/purge_paths") + + local dir + local base + for dir in "$HOME"/*/; do + [[ -d "$dir" ]] || continue + dir="${dir%/}" + base=$(basename "$dir") + + case "$base" in + .* | Library | Applications | Movies | Music | Pictures | Public) + continue + ;; + esac + + if project_cache_has_indicators "$dir" 5; then + roots+=("$dir") fi done - # Fallback: look for project markers near $HOME. - if [[ "$has_dev_projects" == "false" ]]; then - local -a project_markers=( - "node_modules" - ".git" - "target" - "go.mod" - "Cargo.toml" - "package.json" - "pom.xml" - "build.gradle" - ) - local spinner_active=false - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " - start_inline_spinner "Detecting dev projects..." - spinner_active=true - fi - for marker in "${project_markers[@]}"; do - if run_with_timeout 3 sh -c "find '$HOME' -maxdepth 2 -name '$marker' -not -path '*/Library/*' -not -path '*/.Trash/*' 2>/dev/null | head -1" | grep -q .; then - has_dev_projects=true - break - fi - done - if [[ "$spinner_active" == "true" ]]; then - stop_inline_spinner 2> /dev/null || true - # Extra clear to prevent spinner character remnants in terminal - [[ -t 1 ]] && printf "\r\033[2K" >&2 || true - fi - [[ "$has_dev_projects" == "false" ]] && return 0 + + [[ ${#roots[@]} -eq 0 ]] && return 0 + + printf '%s\n' "${roots[@]}" | LC_ALL=C sort -u +} + +# Scan a project root for supported build caches while pruning heavy subtrees. +scan_project_cache_root() { + local root="$1" + local output_file="$2" + local scan_timeout="${MOLE_PROJECT_CACHE_SCAN_TIMEOUT:-6}" + [[ -d "$root" ]] || return 0 + + local -a find_args=( + find -P "$root" -maxdepth 9 -mount + "(" -name "Library" -o -name ".Trash" -o -name "node_modules" -o -name ".git" -o -name ".svn" -o -name ".hg" -o -name ".venv" -o -name "venv" -o -name ".pnpm-store" -o -name ".fvm" -o -name "DerivedData" -o -name "Pods" ")" + -prune -o + -type d + "(" -name ".next" -o -name "__pycache__" -o -name ".dart_tool" ")" + -print + ) + + local status=0 + run_with_timeout "$scan_timeout" "${find_args[@]}" >> "$output_file" 2> /dev/null || status=$? + + if [[ $status -eq 124 ]]; then + debug_log "Project cache scan timed out: $root" + elif [[ $status -ne 0 ]]; then + debug_log "Project cache scan failed (${status}): $root" fi + + return 0 +} + +# Next.js/Python/Flutter project caches scoped to discovered project roots. +clean_project_caches() { + stop_inline_spinner 2> /dev/null || true + + local matches_tmp_file + matches_tmp_file=$(create_temp_file) + + local -a scan_roots=() + local root + while IFS= read -r root; do + [[ -n "$root" ]] && scan_roots+=("$root") + done < <(discover_project_cache_roots) + + [[ ${#scan_roots[@]} -eq 0 ]] && return 0 + if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Searching project caches..." fi - local nextjs_tmp_file - nextjs_tmp_file=$(create_temp_file) - local pycache_tmp_file - pycache_tmp_file=$(create_temp_file) - local flutter_tmp_file - flutter_tmp_file=$(create_temp_file) - local find_timeout=30 - # Parallel scans (Next.js and __pycache__). - # Note: -maxdepth must come before -name for BSD find compatibility - ( - command find -P "$HOME" -maxdepth 3 -mount -type d -name ".next" \ - -not -path "*/Library/*" \ - -not -path "*/.Trash/*" \ - -not -path "*/node_modules/*" \ - -not -path "*/.*" \ - 2> /dev/null || true - ) > "$nextjs_tmp_file" 2>&1 & - local next_pid=$! - ( - command find -P "$HOME" -maxdepth 3 -mount -type d -name "__pycache__" \ - -not -path "*/Library/*" \ - -not -path "*/.Trash/*" \ - -not -path "*/node_modules/*" \ - -not -path "*/.*" \ - 2> /dev/null || true - ) > "$pycache_tmp_file" 2>&1 & - local py_pid=$! - ( - command find -P "$HOME" -maxdepth 5 -mount -type d -name ".dart_tool" \ - -not -path "*/Library/*" \ - -not -path "*/.Trash/*" \ - -not -path "*/node_modules/*" \ - -not -path "*/.fvm/*" \ - 2> /dev/null || true - ) > "$flutter_tmp_file" 2>&1 & - local flutter_pid=$! - local elapsed=0 - local check_interval=0.2 # Check every 200ms instead of 1s for smoother experience - while [[ $(echo "$elapsed < $find_timeout" | awk '{print ($1 < $2)}') -eq 1 ]]; do - if ! kill -0 $next_pid 2> /dev/null && ! kill -0 $py_pid 2> /dev/null && ! kill -0 $flutter_pid 2> /dev/null; then - break - fi - sleep $check_interval - elapsed=$(echo "$elapsed + $check_interval" | awk '{print $1 + $2}') - done - # Kill stuck scans after timeout. - for pid in $next_pid $py_pid $flutter_pid; do - if kill -0 "$pid" 2> /dev/null; then - kill -TERM "$pid" 2> /dev/null || true - local grace_period=0 - while [[ $grace_period -lt 20 ]]; do - if ! kill -0 "$pid" 2> /dev/null; then - break - fi - sleep 0.1 - grace_period=$((grace_period + 1)) - done - if kill -0 "$pid" 2> /dev/null; then - kill -KILL "$pid" 2> /dev/null || true - fi - wait "$pid" 2> /dev/null || true - else - wait "$pid" 2> /dev/null || true - fi + + for root in "${scan_roots[@]}"; do + scan_project_cache_root "$root" "$matches_tmp_file" done + if [[ -t 1 ]]; then stop_inline_spinner fi - while IFS= read -r next_dir; do - [[ -d "$next_dir/cache" ]] && safe_clean "$next_dir/cache"/* "Next.js build cache" || true - done < "$nextjs_tmp_file" - while IFS= read -r pycache; do - [[ -d "$pycache" ]] && safe_clean "$pycache"/* "Python bytecode cache" || true - done < "$pycache_tmp_file" - while IFS= read -r flutter_tool; do - if [[ -d "$flutter_tool" ]]; then - safe_clean "$flutter_tool" "Flutter build cache (.dart_tool)" || true - local build_dir="$(dirname "$flutter_tool")/build" - if [[ -d "$build_dir" ]]; then - safe_clean "$build_dir" "Flutter build cache (build/)" || true - fi - fi - done < "$flutter_tmp_file" + + while IFS= read -r cache_dir; do + case "$(basename "$cache_dir")" in + ".next") + [[ -d "$cache_dir/cache" ]] && safe_clean "$cache_dir/cache"/* "Next.js build cache" || true + ;; + "__pycache__") + [[ -d "$cache_dir" ]] && safe_clean "$cache_dir"/* "Python bytecode cache" || true + ;; + ".dart_tool") + if [[ -d "$cache_dir" ]]; then + safe_clean "$cache_dir" "Flutter build cache (.dart_tool)" || true + local build_dir="$(dirname "$cache_dir")/build" + if [[ -d "$build_dir" ]]; then + safe_clean "$build_dir" "Flutter build cache (build/)" || true + fi + fi + ;; + esac + done < <(LC_ALL=C sort -u "$matches_tmp_file" 2> /dev/null) } diff --git a/lib/core/timeout.sh b/lib/core/timeout.sh index ae9779a..9f1b2f4 100644 --- a/lib/core/timeout.sh +++ b/lib/core/timeout.sh @@ -20,12 +20,18 @@ readonly MOLE_TIMEOUT_LOADED=1 # Recommendation: Install coreutils for reliable timeout support # brew install coreutils # +# Fallback order: +# 1. gtimeout / timeout +# 2. perl helper with dedicated process group cleanup +# 3. shell-based fallback (last resort) +# # The shell-based fallback has known limitations: # - May not clean up all child processes # - Has race conditions in edge cases -# - Less reliable than native timeout command +# - Less reliable than native timeout/perl helper if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then MO_TIMEOUT_BIN="" + MO_TIMEOUT_PERL_BIN="" for candidate in gtimeout timeout; do if command -v "$candidate" > /dev/null 2>&1; then MO_TIMEOUT_BIN="$candidate" @@ -36,8 +42,15 @@ if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then fi done + if [[ -z "$MO_TIMEOUT_BIN" ]] && command -v perl > /dev/null 2>&1; then + MO_TIMEOUT_PERL_BIN="$(command -v perl)" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[TIMEOUT] Using perl fallback: $MO_TIMEOUT_PERL_BIN" >&2 + fi + fi + # Log warning if no timeout command available - if [[ -z "$MO_TIMEOUT_BIN" ]] && [[ "${MO_DEBUG:-0}" == "1" ]]; then + if [[ -z "$MO_TIMEOUT_BIN" && -z "$MO_TIMEOUT_PERL_BIN" ]] && [[ "${MO_DEBUG:-0}" == "1" ]]; then echo "[TIMEOUT] No timeout command found, using shell fallback" >&2 echo "[TIMEOUT] Install coreutils for better reliability: brew install coreutils" >&2 fi @@ -95,6 +108,66 @@ run_with_timeout() { return $? fi + # Use perl helper when timeout command is unavailable. + if [[ -n "${MO_TIMEOUT_PERL_BIN:-}" ]]; then + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[TIMEOUT] Perl fallback, ${duration}s: $*" >&2 + fi + "$MO_TIMEOUT_PERL_BIN" -e ' + use strict; + use warnings; + use POSIX qw(:sys_wait_h setsid); + use Time::HiRes qw(time sleep); + + my $duration = 0 + shift @ARGV; + $duration = 1 if $duration <= 0; + + my $pid = fork(); + defined $pid or exit 125; + + if ($pid == 0) { + setsid() or exit 125; + exec @ARGV; + exit 127; + } + + my $deadline = time() + $duration; + + while (1) { + my $result = waitpid($pid, WNOHANG); + if ($result == $pid) { + if (WIFEXITED($?)) { + exit WEXITSTATUS($?); + } + if (WIFSIGNALED($?)) { + exit 128 + WTERMSIG($?); + } + exit 1; + } + + if (time() >= $deadline) { + kill "TERM", -$pid; + sleep 0.5; + + for (1 .. 6) { + $result = waitpid($pid, WNOHANG); + if ($result == $pid) { + exit 124; + } + sleep 0.25; + } + + kill "KILL", -$pid; + waitpid($pid, 0); + exit 124; + } + + sleep 0.1; + } + ' "$duration" "$@" + return $? + fi + # ======================================================================== # Shell-based fallback implementation # ======================================================================== diff --git a/tests/clean_system_caches.bats b/tests/clean_system_caches.bats index 295113d..a034c96 100644 --- a/tests/clean_system_caches.bats +++ b/tests/clean_system_caches.bats @@ -119,11 +119,13 @@ setup() { } @test "clean_project_caches completes without errors" { - mkdir -p "$HOME/projects/test-app/.next/cache" - mkdir -p "$HOME/projects/python-app/__pycache__" + mkdir -p "$HOME/Projects/test-app/.next/cache" + mkdir -p "$HOME/Projects/python-app/__pycache__" - touch "$HOME/projects/test-app/.next/cache/test.cache" - touch "$HOME/projects/python-app/__pycache__/module.pyc" + touch "$HOME/Projects/test-app/package.json" + touch "$HOME/Projects/python-app/pyproject.toml" + touch "$HOME/Projects/test-app/.next/cache/test.cache" + touch "$HOME/Projects/python-app/__pycache__/module.pyc" run bash -c " export DRY_RUN=true @@ -133,39 +135,142 @@ setup() { " [ "$status" -eq 0 ] - rm -rf "$HOME/projects" + rm -rf "$HOME/Projects" } -@test "clean_project_caches handles timeout gracefully" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" +@test "clean_project_caches scans configured roots instead of HOME" { + mkdir -p "$HOME/.config/mole" + mkdir -p "$HOME/CustomProjects/app/.next/cache" + touch "$HOME/CustomProjects/app/package.json" + + local fake_bin + fake_bin="$(mktemp -d "$HOME/find-bin.XXXXXX")" + local find_log="$HOME/find.log" + + cat > "$fake_bin/find" <> "$find_log" +root="" +prev="" +for arg in "\$@"; do + if [[ "\$prev" == "-P" ]]; then + root="\$arg" + break fi + prev="\$arg" +done +if [[ "\$root" == "$HOME/CustomProjects" ]]; then + printf '%s\n' "$HOME/CustomProjects/app/.next" +fi +EOF + chmod +x "$fake_bin/find" - mkdir -p "$HOME/test-project/.next" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_bin:$PATH" bash --noprofile --norc <<'EOF' +set -euo pipefail +printf '%s\n' "$HOME/CustomProjects" > "$HOME/.config/mole/purge_paths" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/caches.sh" +run_with_timeout() { shift; "$@"; } +safe_clean() { echo "$2|$1"; } +clean_project_caches +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"Next.js build cache"* ]] + grep -q -- "-P $HOME/CustomProjects " "$find_log" + ! grep -q -- "-P $HOME " "$find_log" - function find() { - sleep 2 # Simulate slow find - echo "$HOME/test-project/.next" - } - export -f find + rm -rf "$HOME/CustomProjects" "$HOME/.config/mole" "$fake_bin" "$find_log" +} - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" +@test "clean_project_caches auto-detects top-level project containers" { + mkdir -p "$HOME/go/src/demo/.next/cache" + touch "$HOME/go/src/demo/go.mod" + touch "$HOME/go/src/demo/.next/cache/test.cache" - run $timeout_cmd 15 bash -c " - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/caches.sh' - clean_project_caches - " - [ "$status" -eq 0 ] || [ "$status" -eq 124 ] + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/caches.sh" +safe_clean() { echo "$2|$1"; } +clean_project_caches +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"Next.js build cache|$HOME/go/src/demo/.next/cache/test.cache"* ]] - rm -rf "$HOME/test-project" + rm -rf "$HOME/go" +} + +@test "clean_project_caches auto-detects nested GOPATH-style project containers" { + mkdir -p "$HOME/go/src/github.com/example/demo/.next/cache" + touch "$HOME/go/src/github.com/example/demo/go.mod" + touch "$HOME/go/src/github.com/example/demo/.next/cache/test.cache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/caches.sh" +safe_clean() { echo "$2|$1"; } +clean_project_caches +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"Next.js build cache|$HOME/go/src/github.com/example/demo/.next/cache/test.cache"* ]] + + rm -rf "$HOME/go" +} + +@test "clean_project_caches skips stalled root scans" { + mkdir -p "$HOME/.config/mole" + mkdir -p "$HOME/SlowProjects/app" + printf '%s\n' "$HOME/SlowProjects" > "$HOME/.config/mole/purge_paths" + + local fake_bin + fake_bin="$(mktemp -d "$HOME/find-timeout.XXXXXX")" + + cat > "$fake_bin/find" < "$fake_cmd" <<'EOF' +#!/bin/bash +trap "" TERM +sleep 30 +EOF + chmod +x "$fake_cmd" + + run /usr/bin/perl -e 'alarm 8; exec @ARGV' env FAKE_CMD="$fake_cmd" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/timeout.sh" +MO_TIMEOUT_BIN="" +SECONDS=0 +set +e +run_with_timeout 1 "$FAKE_CMD" +status=$? +set -e +echo "STATUS=$status ELAPSED=$SECONDS" +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"STATUS=124"* ]] + elapsed=$(printf '%s\n' "$output" | awk '{for (i = 1; i <= NF; i++) if ($i ~ /^ELAPSED=/) {split($i, kv, "="); print kv[2]}}' | tail -1) + [[ "$elapsed" =~ ^[0-9]+$ ]] + (( elapsed < 6 )) +} + @test "empty version string is handled gracefully" { result=$(bash -c ' latest=""