diff --git a/bin/devsweep b/bin/devsweep old mode 100755 new mode 100644 index 18e9c62..d79cae6 --- a/bin/devsweep +++ b/bin/devsweep @@ -60,6 +60,8 @@ source "$PROJECT_ROOT/src/modules/docker.sh" source "$PROJECT_ROOT/src/modules/homebrew.sh" # shellcheck source=src/modules/devtools.sh source "$PROJECT_ROOT/src/modules/devtools.sh" +# shellcheck source=src/modules/project_cleanup.sh +source "$PROJECT_ROOT/src/modules/project_cleanup.sh" # Global array for selected modules declare -a SELECTED_MODULES=() @@ -87,6 +89,7 @@ show_help() { echo " --docker Clean Docker/OrbStack (DESTRUCTIVE)" echo " --homebrew Clean Homebrew caches" echo " --devtools Clean dev tools (Maven, Gradle, Node, etc.)" + echo " --projects Clean stale node_modules from inactive projects" echo " --system Clean system logs and caches (requires sudo)" echo " -a, --all Run all cleanup modules" echo "" @@ -113,11 +116,11 @@ show_help() { echo " devsweep --force --all" echo "" echo -e "${CYAN}SAFETY FEATURES:${NC}" - echo " • Dry-run mode to preview all actions" - echo " • Interactive confirmations for dangerous operations" - echo " • Double confirmation (\"type yes\") for destructive actions" - echo " • Keeps latest JetBrains IDE versions automatically" - echo " • Detailed logging of all operations" + echo " \u2022 Dry-run mode to preview all actions" + echo " \u2022 Interactive confirmations for dangerous operations" + echo " \u2022 Double confirmation (\"type yes\") for destructive actions" + echo " \u2022 Keeps latest JetBrains IDE versions automatically" + echo " \u2022 Detailed logging of all operations" echo "" echo -e "${CYAN}MORE INFO:${NC}" echo " GitHub: https://github.com/your-username/devsweep" @@ -176,7 +179,7 @@ parse_arguments() { shift ;; --all) - SELECTED_MODULES=("jetbrains" "docker" "homebrew" "devtools" "system") + SELECTED_MODULES=("jetbrains" "docker" "homebrew" "devtools" "projects" "system") shift ;; --jetbrains) @@ -195,6 +198,10 @@ parse_arguments() { SELECTED_MODULES+=("devtools") shift ;; + --projects) + SELECTED_MODULES+=("projects") + shift + ;; --system) SELECTED_MODULES+=("system") shift @@ -276,6 +283,16 @@ run_modules() { ((failed++)) fi ;; + projects) + if project_cleanup_clean; then + if [[ "$ANALYZE_MODE" != true ]]; then + log_success "Module completed: projects" + fi + else + log_error "Module failed: projects" + ((failed++)) + fi + ;; system) log_warn "System module not yet implemented" # TODO: Implement cleanup_system_caches in src/modules/system.sh @@ -324,10 +341,10 @@ main() { # Show mode indicators if [[ "$DRY_RUN" == true ]]; then - log_warn "🔍 DRY-RUN MODE: No files will be deleted" + log_warn "\U0001f50d DRY-RUN MODE: No files will be deleted" fi if [[ "$FORCE" == true ]]; then - log_warn "⚡ FORCE MODE: Confirmations will be skipped" + log_warn "\u26a1 FORCE MODE: Confirmations will be skipped" fi echo "" @@ -363,13 +380,13 @@ main() { # Show mode indicators if [[ "$DRY_RUN" == true ]]; then - log_warn "🔍 DRY-RUN MODE: No files will be deleted" + log_warn "\U0001f50d DRY-RUN MODE: No files will be deleted" fi if [[ "$ANALYZE_MODE" == true ]]; then - log_info "📊 ANALYZE MODE: Collecting cleanup preview..." + log_info "\U0001f4ca ANALYZE MODE: Collecting cleanup preview..." fi if [[ "$FORCE" == true ]]; then - log_warn "⚡ FORCE MODE: Confirmations will be skipped" + log_warn "\u26a1 FORCE MODE: Confirmations will be skipped" fi echo "" diff --git a/src/modules/project_cleanup.sh b/src/modules/project_cleanup.sh new file mode 100644 index 0000000..97bcfcd --- /dev/null +++ b/src/modules/project_cleanup.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# project_cleanup.sh - Stale node_modules cleanup module +# Scans HOME for node_modules directories in inactive projects and removes them + +set -euo pipefail + +# Prevent double-sourcing +if [[ -n "${DEVSWEEP_PROJECT_CLEANUP_LOADED:-}" ]]; then + return 0 +fi +readonly DEVSWEEP_PROJECT_CLEANUP_LOADED=true + +# ============================================================ +# HELPERS +# ============================================================ + +# Replace HOME prefix with ~ for display +shorten_path() { + echo "${1/$HOME/~}" +} + +# Returns 0 when no project file has been touched within NODE_MODULES_STALE_DAYS. +# node_modules and .git directories are excluded from the activity check. +# Args: $1=project_dir +is_project_inactive() { + local project_dir="$1" + + # head -1 short-circuits as soon as one recent file is found + local active_file + active_file=$(find "$project_dir" \ + -maxdepth 5 -type f \ + ! -path "*/node_modules/*" \ + ! -path "*/.git/*" \ + -mtime -"${NODE_MODULES_STALE_DAYS}" 2>/dev/null | head -1) + + [[ -z "$active_file" ]] +} + +# Print the number of days since the most recent source-file modification. +# Returns 9999 when the project contains no source files at all. +# Args: $1=project_dir +get_project_inactive_days() { + local project_dir="$1" + + local latest_epoch + latest_epoch=$(find "$project_dir" \ + -type f \ + ! -path "*/node_modules/*" \ + ! -path "*/.git/*" \ + -exec stat -f %m {} + 2>/dev/null | sort -rn | head -1) + + if [[ -z "$latest_epoch" ]]; then + echo 9999 + return + fi + + local now + now=$(date +%s) + echo $(( (now - latest_epoch) / 86400 )) +} + +# ============================================================ +# SCAN +# ============================================================ + +# Print every top-level node_modules directory found under HOME. +# Skips: nested node_modules, macOS Library, .Trash. +find_node_modules() { + find "${HOME}" \ + -maxdepth "$NODE_MODULES_MAX_DEPTH" \ + -type d -name "node_modules" \ + ! -path "*/node_modules/*" \ + ! -path "${HOME}/Library/*" \ + ! -path "${HOME}/.Trash/*" \ + 2>/dev/null +} + +# ============================================================ +# CLEANUP +# ============================================================ + +# Scan, list, confirm and remove stale node_modules. +# Respects DRY_RUN, ANALYZE_MODE, and FORCE. +# Returns: 0 on success +cleanup_stale_node_modules() { + log_cleanup_section "Stale node_modules Cleanup" + log_cleanup_info "Scanning for inactive projects..." + + # Collect stale projects while staying in the current shell + local stale_projects=() + local stale_sizes=() + local stale_days=() + local total_size_kb=0 + + while IFS= read -r nm_dir; do + [[ -z "$nm_dir" ]] && continue + + local project_dir + project_dir="$(dirname "$nm_dir")" + + # Skip empty node_modules + if [[ -z "$(ls -A "$nm_dir" 2>/dev/null)" ]]; then + continue + fi + + if is_project_inactive "$project_dir"; then + local size days size_kb + size=$(get_size "$nm_dir") + days=$(get_project_inactive_days "$project_dir") + size_kb=$(parse_size_to_kb "$size") + + stale_projects+=("$project_dir") + stale_sizes+=("$size") + stale_days+=("$days") + total_size_kb=$((total_size_kb + size_kb)) + fi + done < <(find_node_modules) + + if [[ ${#stale_projects[@]} -eq 0 ]]; then + log_cleanup_info "No inactive projects with node_modules found" + return 0 + fi + + local total_count=${#stale_projects[@]} + local total_size_human + total_size_human=$(format_kb_to_human "$total_size_kb") + + # Display & analyze-mode registration + log_cleanup_info "Found $total_count project(s) with stale node_modules:" + if [[ "$ANALYZE_MODE" != true ]]; then + echo "" + fi + + local i=0 + for project in "${stale_projects[@]}"; do + local short_path + short_path=$(shorten_path "$project") + + if [[ "$ANALYZE_MODE" == true ]]; then + add_analyze_item "Projects" "node_modules in ${short_path} (inactive ${stale_days[$i]}d)" "${stale_sizes[$i]}" + else + echo " ${short_path} (inactive ${stale_days[$i]} days, ${stale_sizes[$i]})" + fi + i=$((i + 1)) + done + + [[ "$ANALYZE_MODE" == true ]] && return 0 + + echo "" + + # Dry-run + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY-RUN] Would remove node_modules from $total_count project(s) ($total_size_human total)" + return 0 + fi + + # Confirm before bulk delete + if ! confirm_action "Remove node_modules from $total_count project(s) ($total_size_human total)?"; then + log_info "Stale node_modules cleanup skipped" + return 0 + fi + + # Delete + for project in "${stale_projects[@]}"; do + local short_path + short_path=$(shorten_path "$project") + safe_rm "${project}/node_modules" "node_modules in ${short_path}" + done + + log_success "Stale node_modules cleaned ($total_size_human freed)" +} + +# ============================================================ +# ENTRY POINT +# ============================================================ + +# Usage: project_cleanup_clean +# Returns: 0 on success +project_cleanup_clean() { + cleanup_stale_node_modules +} diff --git a/src/utils/config.sh b/src/utils/config.sh index 4074f87..d826db5 100644 --- a/src/utils/config.sh +++ b/src/utils/config.sh @@ -52,6 +52,10 @@ readonly NPM_CACHE_PATH="${HOME}/.npm/_cacache" readonly NVM_CACHE_PATH="${HOME}/.nvm/.cache" readonly SDKMAN_TMP_PATH="${HOME}/.sdkman/tmp" +# Stale node_modules scanning (search rooted at HOME) +readonly NODE_MODULES_STALE_DAYS=90 +readonly NODE_MODULES_MAX_DEPTH=6 + readonly CHROME_AI_MODEL_PATH="${HOME}/Library/Application Support/Google/Chrome/OptGuideOnDeviceModel" readonly SYSTEM_LOG_PATH="/private/var/log" diff --git a/tests/unit/project_cleanup_test.sh b/tests/unit/project_cleanup_test.sh new file mode 100644 index 0000000..780376b --- /dev/null +++ b/tests/unit/project_cleanup_test.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# project_cleanup_test.sh - Tests for stale node_modules cleanup module + +# Get project root +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$TEST_DIR/../.." && pwd)" + +# Source dependencies +source "$PROJECT_ROOT/src/utils/config.sh" +source "$PROJECT_ROOT/src/utils/common.sh" +source "$PROJECT_ROOT/src/modules/project_cleanup.sh" + +# ============================================================ +# SETUP AND TEARDOWN +# ============================================================ + +ORIGINAL_HOME="" + +function set_up() { + DRY_RUN=true + VERBOSE=false + FORCE=true + ANALYZE_MODE=false + ANALYZE_ITEMS=() + TOTAL_SPACE_FREED_KB=0 + + TEST_TEMP_DIR="$(mktemp -d)" + ORIGINAL_HOME="$HOME" +} + +function tear_down() { + HOME="$ORIGINAL_HOME" + if [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +# ============================================================ +# HELPER: create a project with node_modules and control timestamps +# Args: $1=base_dir $2=project_name $3=timestamp (touch -t format, empty = now) +# ============================================================ + +create_project() { + local base="$1" + local name="$2" + local ts="$3" # e.g. "202507010000" or empty for current time + + local dir="$base/$name" + mkdir -p "$dir/node_modules/some-pkg" + echo '{"name":"some-pkg"}' > "$dir/node_modules/some-pkg/package.json" + echo '{"name":"'"$name"'"}' > "$dir/package.json" + echo "console.log('hi')" > "$dir/index.js" + + if [[ -n "$ts" ]]; then + touch -t "$ts" "$dir/package.json" "$dir/index.js" + fi +} + +# ============================================================ +# HELPER FUNCTION TESTS +# ============================================================ + +function test_is_project_inactive_detects_stale_project() { + local project_dir="$TEST_TEMP_DIR/stale" + mkdir -p "$project_dir" + echo "x" > "$project_dir/index.js" + # Jul 2025 \u2192 well over 90 days ago from 2026-02-05 + touch -t 202507010000 "$project_dir/index.js" + + is_project_inactive "$project_dir" + assert_successful_code "$?" +} + +function test_is_project_inactive_skips_active_project() { + local project_dir="$TEST_TEMP_DIR/active" + mkdir -p "$project_dir" + echo "x" > "$project_dir/index.js" + # Default timestamp = now \u2192 active + + is_project_inactive "$project_dir" + local result=$? + + if [[ "$result" -eq 0 ]]; then + assert_fail "Active project should not be detected as inactive" + fi +} + +function test_is_project_inactive_ignores_node_modules_timestamps() { + # Project source files are old, but node_modules has a recent file. + # The recent file inside node_modules must NOT count as activity. + local project_dir="$TEST_TEMP_DIR/nm-ignore" + mkdir -p "$project_dir/node_modules" + echo "x" > "$project_dir/index.js" + touch -t 202507010000 "$project_dir/index.js" + # Recent file inside node_modules \u2014 should be ignored + echo "y" > "$project_dir/node_modules/recent.txt" + + is_project_inactive "$project_dir" + assert_successful_code "$?" +} + +function test_is_project_inactive_ignores_git_timestamps() { + # .git directory has recent files but source is old + local project_dir="$TEST_TEMP_DIR/git-ignore" + mkdir -p "$project_dir/.git" + echo "x" > "$project_dir/index.js" + touch -t 202507010000 "$project_dir/index.js" + echo "y" > "$project_dir/.git/HEAD" + + is_project_inactive "$project_dir" + assert_successful_code "$?" +} + +function test_get_project_inactive_days_returns_positive_number() { + local project_dir="$TEST_TEMP_DIR/days-calc" + mkdir -p "$project_dir" + echo "x" > "$project_dir/README.md" + touch -t 202507010000 "$project_dir/README.md" + + local days + days=$(get_project_inactive_days "$project_dir") + + # Jul 2025 to Feb 2026 > 200 days + if [[ "$days" -lt 200 ]]; then + assert_fail "Expected >200 inactive days, got $days" + fi +} + +function test_get_project_inactive_days_returns_9999_for_empty_project() { + local project_dir="$TEST_TEMP_DIR/no-source" + mkdir -p "$project_dir/node_modules" + # No source files at all + + local days + days=$(get_project_inactive_days "$project_dir") + + assert_equals 9999 "$days" +} + +# ============================================================ +# SCAN & CLEANUP TESTS (override HOME \u2192 temp dir) +# ============================================================ + +function test_dry_run_preserves_stale_node_modules() { + HOME="$TEST_TEMP_DIR" + create_project "$TEST_TEMP_DIR" "old-app" "202507010000" + + DRY_RUN=true + cleanup_stale_node_modules + + if [[ ! -d "$TEST_TEMP_DIR/old-app/node_modules" ]]; then + assert_fail "node_modules was deleted in dry-run mode" + fi +} + +function test_analyze_mode_registers_stale_projects() { + HOME="$TEST_TEMP_DIR" + create_project "$TEST_TEMP_DIR" "analyze-app" "202507010000" + + ANALYZE_MODE=true + ANALYZE_ITEMS=() + + cleanup_stale_node_modules + + local found=false + for item in "${ANALYZE_ITEMS[@]:-}"; do + if [[ "$item" == *"node_modules"* ]]; then + found=true + break + fi + done + assert_true "$found" "Stale project should be registered in ANALYZE_ITEMS" +} + +function test_skips_empty_node_modules() { + HOME="$TEST_TEMP_DIR" + + local project_dir="$TEST_TEMP_DIR/empty-nm" + mkdir -p "$project_dir/node_modules" # empty dir + echo "x" > "$project_dir/package.json" + touch -t 202507010000 "$project_dir/package.json" + + ANALYZE_MODE=true + ANALYZE_ITEMS=() + + cleanup_stale_node_modules + + # Nothing should be registered \u2014 node_modules is empty + if [[ ${#ANALYZE_ITEMS[@]} -gt 0 ]]; then + assert_fail "Empty node_modules should not be reported" + fi +} + +function test_active_project_node_modules_not_removed() { + HOME="$TEST_TEMP_DIR" + # Active project: source files have current timestamps + create_project "$TEST_TEMP_DIR" "active-app" "" + + DRY_RUN=false + FORCE=true + cleanup_stale_node_modules + + if [[ ! -d "$TEST_TEMP_DIR/active-app/node_modules" ]]; then + assert_fail "node_modules was removed from an active project" + fi +} + +function test_handles_no_projects_gracefully() { + HOME="$TEST_TEMP_DIR" + # Empty temp dir \u2014 nothing to find + + DRY_RUN=true + cleanup_stale_node_modules + assert_successful_code "$?" +} + +function test_removes_stale_node_modules_when_confirmed() { + HOME="$TEST_TEMP_DIR" + create_project "$TEST_TEMP_DIR" "confirmed-app" "202507010000" + + DRY_RUN=false + FORCE=true # auto-confirms + cleanup_stale_node_modules + + if [[ -d "$TEST_TEMP_DIR/confirmed-app/node_modules" ]]; then + assert_fail "Stale node_modules should have been removed" + fi + + # Project root must survive \u2014 only node_modules deleted + if [[ ! -d "$TEST_TEMP_DIR/confirmed-app" ]]; then + assert_fail "Project root directory should not be deleted" + fi +} + +function test_multiple_stale_projects_all_cleaned() { + HOME="$TEST_TEMP_DIR" + create_project "$TEST_TEMP_DIR" "proj-a" "202507010000" + create_project "$TEST_TEMP_DIR" "proj-b" "202506010000" + create_project "$TEST_TEMP_DIR" "proj-c" "202504010000" + + DRY_RUN=false + FORCE=true + cleanup_stale_node_modules + + local all_gone=true + for p in proj-a proj-b proj-c; do + if [[ -d "$TEST_TEMP_DIR/$p/node_modules" ]]; then + all_gone=false + fi + done + + assert_true "$all_gone" "All stale node_modules should be removed" +} + +function test_mixed_active_and_stale_only_stale_removed() { + HOME="$TEST_TEMP_DIR" + create_project "$TEST_TEMP_DIR" "stale-mix" "202507010000" + create_project "$TEST_TEMP_DIR" "active-mix" "" # current timestamps + + DRY_RUN=false + FORCE=true + cleanup_stale_node_modules + + # Stale must be gone + if [[ -d "$TEST_TEMP_DIR/stale-mix/node_modules" ]]; then + assert_fail "Stale project node_modules should be removed" + fi + + # Active must survive + if [[ ! -d "$TEST_TEMP_DIR/active-mix/node_modules" ]]; then + assert_fail "Active project node_modules should not be removed" + fi +}