mirror of
https://github.com/tw93/Mole.git
synced 2026-02-06 02:38:01 +00:00
feat: add interactive purge command with comprehensive tests
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,4 +52,3 @@ cmd/status/status
|
||||
/status
|
||||
mole-analyze
|
||||
# Note: bin/analyze-go and bin/status-go are released binaries and should be tracked
|
||||
.mole_cleanup_stats
|
||||
|
||||
25
README.md
25
README.md
@@ -18,10 +18,10 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **All-in-one toolkit** combining the power of CleanMyMac, AppCleaner, DaisyDisk, Sensei, and iStat in one **trusted binary**
|
||||
- **All-in-one toolkit** combining CleanMyMac, AppCleaner, DaisyDisk, Sensei, and iStat in one **trusted binary**
|
||||
- **Deep cleanup** scans and removes caches, logs, browser leftovers, and junk to **reclaim tens of gigabytes**
|
||||
- **Smart uninstall** completely removes apps including launch agents, preferences, caches, and **hidden leftovers**
|
||||
- **Disk insight + optimization** visualizes usage, handles large files, **rebuilds caches**, cleans swap, and refreshes services
|
||||
- **Disk insight + optimization** visualizes usage, handles large files, **rebuilds caches**, and refreshes services
|
||||
- **Live status** monitors CPU, GPU, memory, disk, network, battery, and proxy stats to **diagnose issues**
|
||||
|
||||
## Quick Start
|
||||
@@ -59,7 +59,6 @@ mo clean --dry-run # Preview cleanup plan
|
||||
mo clean --whitelist # Adjust protected caches
|
||||
mo uninstall --force-rescan # Rescan apps and refresh cache
|
||||
mo optimize --whitelist # Adjust protected optimization items
|
||||
|
||||
```
|
||||
|
||||
## Tips
|
||||
@@ -184,16 +183,24 @@ Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded
|
||||
|
||||
### Project Artifact Purge
|
||||
|
||||
Remove build artifacts from old projects to reclaim disk space. Fast parallel scanning targets `node_modules`, `target`, `build`, `dist`, `.next`, `.gradle`, `venv`, and similar directories.
|
||||
Clean old build artifacts (`node_modules`, `target`, `build`, `dist`, etc.) from your projects to free up disk space.
|
||||
|
||||
```bash
|
||||
mo purge --dry-run # Preview cleanup (recommended)
|
||||
mo purge # Clean old project artifacts
|
||||
mo purge
|
||||
|
||||
Select Categories to Clean - 18.5GB (8 selected)
|
||||
|
||||
➤ ● my-react-app 3.2GB | node_modules
|
||||
● old-project 2.8GB | node_modules
|
||||
● rust-app 4.1GB | target
|
||||
● next-blog 1.9GB | node_modules
|
||||
○ current-work 856MB | node_modules | Recent
|
||||
● django-api 2.3GB | venv
|
||||
● vue-dashboard 1.7GB | node_modules
|
||||
● backend-service 2.5GB | node_modules
|
||||
```
|
||||
|
||||
**Safety:** Only scans common project directories, skips recently modified projects (7 days), and requires artifacts at least 2 levels deep to avoid system files.
|
||||
|
||||
**Performance:** Uses macOS Spotlight index (mdfind) for lightning-fast scanning, with parallel search across multiple directories.
|
||||
> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects (< 7 days) are marked and unselected by default.
|
||||
|
||||
## Quick Launchers
|
||||
|
||||
|
||||
@@ -498,7 +498,6 @@ EOF
|
||||
|
||||
# Check for cancel (ESC or Q)
|
||||
if [[ "$choice" == "QUIT" ]]; then
|
||||
echo -e " ${GRAY}Cancelled${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -445,14 +445,12 @@ main() {
|
||||
|
||||
local key
|
||||
if ! key=$(read_key); then
|
||||
echo -e " ${GRAY}Cancelled${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$key" == "ENTER" ]]; then
|
||||
printf "\r\033[K"
|
||||
else
|
||||
echo -e " ${GRAY}Cancelled${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
79
bin/purge.sh
79
bin/purge.sh
@@ -15,10 +15,6 @@ source "$SCRIPT_DIR/../lib/core/log.sh"
|
||||
source "$SCRIPT_DIR/../lib/clean/project.sh"
|
||||
|
||||
# Configuration
|
||||
DRY_RUN=false
|
||||
|
||||
# Export list configuration
|
||||
EXPORT_LIST_FILE="$HOME/.config/mole/purge-list.txt"
|
||||
CURRENT_SECTION=""
|
||||
|
||||
# Section management
|
||||
@@ -48,75 +44,61 @@ start_purge() {
|
||||
fi
|
||||
printf '\n'
|
||||
echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}"
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e "${GRAY}${ICON_SOLID}${NC} Dry run mode - previewing what would be cleaned"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Prepare export list
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
mkdir -p "$(dirname "$EXPORT_LIST_FILE")"
|
||||
: > "$EXPORT_LIST_FILE"
|
||||
fi
|
||||
|
||||
# Initialize stats file
|
||||
echo "0" > "$SCRIPT_DIR/../.mole_cleanup_stats"
|
||||
echo "0" > "$SCRIPT_DIR/../.mole_cleanup_count"
|
||||
# Initialize stats file in user cache directory
|
||||
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
||||
mkdir -p "$stats_dir"
|
||||
echo "0" > "$stats_dir/purge_stats"
|
||||
echo "0" > "$stats_dir/purge_count"
|
||||
}
|
||||
|
||||
# Perform the purge
|
||||
perform_purge() {
|
||||
clean_project_artifacts
|
||||
local exit_code=$?
|
||||
|
||||
# Exit codes:
|
||||
# 0 = success, show summary
|
||||
# 1 = user cancelled
|
||||
# 2 = nothing to clean
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Final summary (matching clean.sh format)
|
||||
echo ""
|
||||
|
||||
local summary_heading=""
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
summary_heading="Purge complete - dry run"
|
||||
else
|
||||
summary_heading="Purge complete"
|
||||
fi
|
||||
|
||||
local summary_heading="Purge complete"
|
||||
local -a summary_details=()
|
||||
local total_size_cleaned=0
|
||||
local total_items_cleaned=0
|
||||
|
||||
# Read stats
|
||||
if [[ -f "$SCRIPT_DIR/../.mole_cleanup_stats" ]]; then
|
||||
total_size_cleaned=$(cat "$SCRIPT_DIR/../.mole_cleanup_stats" 2> /dev/null || echo "0")
|
||||
rm -f "$SCRIPT_DIR/../.mole_cleanup_stats"
|
||||
# Read stats from user cache directory
|
||||
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
||||
|
||||
if [[ -f "$stats_dir/purge_stats" ]]; then
|
||||
total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
|
||||
rm -f "$stats_dir/purge_stats"
|
||||
fi
|
||||
|
||||
# Read count
|
||||
if [[ -f "$SCRIPT_DIR/../.mole_cleanup_count" ]]; then
|
||||
total_items_cleaned=$(cat "$SCRIPT_DIR/../.mole_cleanup_count" 2> /dev/null || echo "0")
|
||||
rm -f "$SCRIPT_DIR/../.mole_cleanup_count"
|
||||
if [[ -f "$stats_dir/purge_count" ]]; then
|
||||
total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0")
|
||||
rm -f "$stats_dir/purge_count"
|
||||
fi
|
||||
|
||||
if [[ $total_size_cleaned -gt 0 ]]; then
|
||||
local freed_gb
|
||||
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
summary_details+=("Potential space: ${GREEN}${freed_gb}GB${NC}")
|
||||
else
|
||||
summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}")
|
||||
summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}")
|
||||
summary_details+=("Free space now: $(get_free_space)")
|
||||
|
||||
if [[ $total_items_cleaned -gt 0 ]]; then
|
||||
summary_details+=("Items cleaned: $total_items_cleaned")
|
||||
fi
|
||||
|
||||
summary_details+=("Free space now: $(get_free_space)")
|
||||
if [[ $total_items_cleaned -gt 0 ]]; then
|
||||
summary_details+=("Items cleaned: $total_items_cleaned")
|
||||
fi
|
||||
else
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
summary_details+=("No old project artifacts found.")
|
||||
else
|
||||
summary_details+=("No old project artifacts to clean.")
|
||||
fi
|
||||
summary_details+=("No old project artifacts to clean.")
|
||||
summary_details+=("Free space now: $(get_free_space)")
|
||||
fi
|
||||
|
||||
@@ -135,9 +117,6 @@ main() {
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
"--dry-run" | "-n")
|
||||
DRY_RUN=true
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
echo "Use 'mo --help' for usage information"
|
||||
|
||||
@@ -55,8 +55,8 @@ is_safe_project_artifact() {
|
||||
local depth=$(echo "$relative_path" | tr -cd '/' | wc -c)
|
||||
|
||||
# Require at least 1 level deep (inside a project folder)
|
||||
# e.g., ~/www/MyProject/node_modules is OK (depth >= 1)
|
||||
# but ~/www/node_modules is NOT OK (depth = 0)
|
||||
# e.g., ~/www/weekly/node_modules is OK (depth >= 1)
|
||||
# but ~/www/node_modules is NOT OK (depth < 1)
|
||||
if [[ $depth -lt 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
@@ -171,7 +171,8 @@ filter_nested_artifacts() {
|
||||
for target in "${PURGE_TARGETS[@]}"; do
|
||||
# Check if parent directory IS a target or IS INSIDE a target
|
||||
# e.g. .../node_modules/foo/node_modules -> parent has node_modules
|
||||
if [[ "$parent_dir" == *"/$target"* || "$parent_dir" == *"/$target" ]]; then
|
||||
# Use more strict matching to avoid false positives like "my_node_modules_backup"
|
||||
if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then
|
||||
is_nested=true
|
||||
break
|
||||
fi
|
||||
@@ -218,107 +219,169 @@ get_dir_size_kb() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Simplified clean function for project artifacts
|
||||
# Args: $1 - path, $2 - description
|
||||
safe_clean() {
|
||||
local path="$1"
|
||||
local description="$2"
|
||||
# Simple category selector (for purge only)
|
||||
# Args: category names and metadata as arrays (passed via global vars)
|
||||
# Returns: selected indices in PURGE_SELECTION_RESULT (comma-separated)
|
||||
# Uses PURGE_RECENT_CATEGORIES to mark categories with recent items (default unselected)
|
||||
select_purge_categories() {
|
||||
local -a categories=("$@")
|
||||
local total_items=${#categories[@]}
|
||||
|
||||
if [[ ! -e "$path" ]]; then
|
||||
return 0
|
||||
if [[ $total_items -eq 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Get size before deletion
|
||||
local size_kb=$(get_dir_size_kb "$path")
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
if [[ $size_kb -gt 0 ]]; then
|
||||
local size_mb=$((size_kb / 1024))
|
||||
echo -e "${GRAY}Would remove:${NC} $description (~${size_mb}MB)"
|
||||
# Initialize selection (all selected by default, except recent ones)
|
||||
local -a selected=()
|
||||
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
# Default unselected if category has recent items
|
||||
if [[ ${recent_flags[i]:-false} == "true" ]]; then
|
||||
selected[i]=false
|
||||
else
|
||||
selected[i]=true
|
||||
fi
|
||||
else
|
||||
if [[ $size_kb -gt 0 ]]; then
|
||||
local size_mb=$((size_kb / 1024))
|
||||
done
|
||||
|
||||
# Show cleaning status (transient) with spinner
|
||||
if [[ -t 1 ]]; then
|
||||
# Use standard spinner prefix or none as requested?
|
||||
# User asked for "no indentation". MOLE_SPINNER_PREFIX controls indentation in ui.sh.
|
||||
# But ui.sh often adds " |".
|
||||
# Let's use start_inline_spinner which uses MOLE_SPINNER_PREFIX.
|
||||
# We can temporarily clear prefix to avoid indentation if needed,
|
||||
# but standard UI guidelines might suggest some alignment.
|
||||
# The user specifically said "不要缩进".
|
||||
local original_prefix="${MOLE_SPINNER_PREFIX:-}"
|
||||
MOLE_SPINNER_PREFIX="" start_inline_spinner "Cleaning $description (~${size_mb}MB)..."
|
||||
|
||||
rm -rf "$path" 2> /dev/null || true
|
||||
|
||||
stop_inline_spinner
|
||||
MOLE_SPINNER_PREFIX="$original_prefix"
|
||||
else
|
||||
rm -rf "$path" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
if [[ ! -e "$path" ]]; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed $description (~${size_mb}MB)"
|
||||
|
||||
# Update stats file
|
||||
if [[ -f "$SCRIPT_DIR/../.mole_cleanup_stats" ]]; then
|
||||
local current_total=$(cat "$SCRIPT_DIR/../.mole_cleanup_stats")
|
||||
local new_total=$((current_total + size_kb))
|
||||
echo "$new_total" > "$SCRIPT_DIR/../.mole_cleanup_stats"
|
||||
fi
|
||||
|
||||
# Update count file
|
||||
local count_file="$SCRIPT_DIR/../.mole_cleanup_count"
|
||||
local current_count=0
|
||||
if [[ -f "$count_file" ]]; then
|
||||
current_count=$(cat "$count_file")
|
||||
fi
|
||||
echo $((current_count + 1)) > "$count_file"
|
||||
else
|
||||
echo -e "${RED}${ICON_CROSS}${NC} Failed to remove $description"
|
||||
fi
|
||||
fi
|
||||
local cursor_pos=0
|
||||
local original_stty=""
|
||||
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
|
||||
original_stty=$(stty -g 2> /dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Terminal control functions
|
||||
restore_terminal() {
|
||||
trap - EXIT INT TERM
|
||||
show_cursor
|
||||
if [[ -n "${original_stty:-}" ]]; then
|
||||
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
handle_interrupt() {
|
||||
restore_terminal
|
||||
exit 130
|
||||
}
|
||||
|
||||
draw_menu() {
|
||||
printf "\033[H\033[2J"
|
||||
# Calculate total size of selected items for header
|
||||
local selected_size=0
|
||||
local selected_count=0
|
||||
IFS=',' read -r -a sizes <<< "${PURGE_CATEGORY_SIZES:-}"
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
selected_size=$((selected_size + ${sizes[i]:-0}))
|
||||
((selected_count++))
|
||||
fi
|
||||
done
|
||||
local selected_gb=$(echo "scale=1; $selected_size/1024/1024" | bc)
|
||||
|
||||
printf '\n'
|
||||
echo -e "${PURPLE_BOLD}Select Categories to Clean${NC} ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}"
|
||||
echo ""
|
||||
|
||||
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
local checkbox="$ICON_EMPTY"
|
||||
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
|
||||
|
||||
local recent_marker=""
|
||||
[[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}"
|
||||
|
||||
if [[ $i -eq $cursor_pos ]]; then
|
||||
printf "\r\033[2K${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$checkbox" "${categories[i]}" "$recent_marker"
|
||||
else
|
||||
printf "\r\033[2K %s %s%s\n" "$checkbox" "${categories[i]}" "$recent_marker"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${GRAY}↑↓ | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}"
|
||||
}
|
||||
|
||||
trap restore_terminal EXIT
|
||||
trap handle_interrupt INT TERM
|
||||
|
||||
# Preserve interrupt character for Ctrl-C
|
||||
stty -echo -icanon intr ^C 2> /dev/null || true
|
||||
hide_cursor
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
draw_menu
|
||||
|
||||
# Read key
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
|
||||
case "$key" in
|
||||
$'\x1b')
|
||||
# Arrow keys or ESC
|
||||
# Read next 2 chars with timeout (bash 3.2 needs integer)
|
||||
IFS= read -r -s -n1 -t 1 key2 || key2=""
|
||||
if [[ "$key2" == "[" ]]; then
|
||||
IFS= read -r -s -n1 -t 1 key3 || key3=""
|
||||
case "$key3" in
|
||||
A) # Up arrow
|
||||
((cursor_pos > 0)) && ((cursor_pos--))
|
||||
;;
|
||||
B) # Down arrow
|
||||
((cursor_pos < total_items - 1)) && ((cursor_pos++))
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# ESC alone (no following chars)
|
||||
restore_terminal
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
" ") # Space - toggle current item
|
||||
if [[ ${selected[cursor_pos]} == true ]]; then
|
||||
selected[cursor_pos]=false
|
||||
else
|
||||
selected[cursor_pos]=true
|
||||
fi
|
||||
;;
|
||||
"a"|"A") # Select all
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
selected[i]=true
|
||||
done
|
||||
;;
|
||||
"i"|"I") # Invert selection
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
selected[i]=false
|
||||
else
|
||||
selected[i]=true
|
||||
fi
|
||||
done
|
||||
;;
|
||||
"q"|"Q"|$'\x03') # Quit or Ctrl-C
|
||||
restore_terminal
|
||||
return 1
|
||||
;;
|
||||
""|$'\n'|$'\r') # Enter - confirm
|
||||
# Build result
|
||||
PURGE_SELECTION_RESULT=""
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
|
||||
PURGE_SELECTION_RESULT+="$i"
|
||||
fi
|
||||
done
|
||||
|
||||
restore_terminal
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Main cleanup function
|
||||
# Env: DRY_RUN
|
||||
# Main cleanup function - scans and prompts user to select artifacts to clean
|
||||
clean_project_artifacts() {
|
||||
local -a all_found_items=()
|
||||
local -a safe_to_clean=()
|
||||
local -a recently_modified=()
|
||||
local total_found_size=0 # in KB
|
||||
|
||||
# Show warning and ask for confirmation (not in dry-run mode)
|
||||
if [[ "$DRY_RUN" != "true" && -t 0 ]]; then
|
||||
echo -e "${GRAY}${ICON_SOLID}${NC} Will remove old project build artifacts, use --dry-run to preview"
|
||||
echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue, ${GRAY}ESC${NC} to cancel: "
|
||||
|
||||
# Read single key
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
drain_pending_input
|
||||
case "$key" in
|
||||
$'\e')
|
||||
echo ""
|
||||
echo -e "${GRAY}Cancelled${NC}"
|
||||
printf '\n'
|
||||
exit 0
|
||||
;;
|
||||
"" | $'\n' | $'\r')
|
||||
printf "\r\033[K"
|
||||
# Continue with scan
|
||||
;;
|
||||
*)
|
||||
echo ""
|
||||
echo -e "${GRAY}Cancelled${NC}"
|
||||
printf '\n'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Set up cleanup on interrupt
|
||||
local scan_pids=()
|
||||
@@ -345,7 +408,7 @@ clean_project_artifacts() {
|
||||
|
||||
# Start parallel scanning of all paths at once
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Scanning project directories (please wait)..."
|
||||
start_inline_spinner "Scanning projects..."
|
||||
fi
|
||||
|
||||
# Launch all scans in parallel
|
||||
@@ -387,50 +450,179 @@ clean_project_artifacts() {
|
||||
trap - INT TERM
|
||||
|
||||
if [[ ${#all_found_items[@]} -eq 0 ]]; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} No project artifacts found."
|
||||
note_activity
|
||||
return
|
||||
fi
|
||||
|
||||
# Filter items based on modification time
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Analyzing artifacts..."
|
||||
echo ""
|
||||
echo -e "${GREEN}✓${NC} Great! No old project artifacts to clean"
|
||||
printf '\n'
|
||||
return 2 # Special code: nothing to clean
|
||||
fi
|
||||
|
||||
# Mark recently modified items (for default selection state)
|
||||
for item in "${all_found_items[@]}"; do
|
||||
if is_recently_modified "$item"; then
|
||||
recently_modified+=("$item")
|
||||
else
|
||||
safe_to_clean+=("$item")
|
||||
local item_size=$(get_dir_size_kb "$item")
|
||||
total_found_size=$((total_found_size + item_size))
|
||||
fi
|
||||
# Add all items to safe_to_clean, let user choose
|
||||
safe_to_clean+=("$item")
|
||||
done
|
||||
|
||||
# Build menu options - one per artifact
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Calculating sizes..."
|
||||
fi
|
||||
|
||||
local -a menu_options=()
|
||||
local -a item_paths=()
|
||||
local -a item_sizes=()
|
||||
local -a item_recent_flags=()
|
||||
|
||||
# Helper to get project name from path
|
||||
# For ~/www/pake/src-tauri/target -> returns "pake"
|
||||
# For ~/www/project/node_modules/xxx/node_modules -> returns "project"
|
||||
get_project_name() {
|
||||
local path="$1"
|
||||
|
||||
# Find the project root by looking for direct child of search paths
|
||||
local search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects")
|
||||
|
||||
for root in "${search_roots[@]}"; do
|
||||
if [[ "$path" == "$root/"* ]]; then
|
||||
# Remove root prefix and get first directory component
|
||||
local relative_path="${path#$root/}"
|
||||
# Extract first directory name
|
||||
echo "$relative_path" | cut -d'/' -f1
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Fallback: use grandparent directory
|
||||
dirname "$(dirname "$path")" | xargs basename
|
||||
}
|
||||
|
||||
# Format display with alignment (like app_selector)
|
||||
format_purge_display() {
|
||||
local project_name="$1"
|
||||
local artifact_type="$2"
|
||||
local size_str="$3"
|
||||
|
||||
# Terminal width for alignment
|
||||
local terminal_width=$(tput cols 2>/dev/null || echo 80)
|
||||
local fixed_width=28 # Reserve for type and size
|
||||
local available_width=$((terminal_width - fixed_width))
|
||||
|
||||
# Bounds: 24-35 chars for project name
|
||||
[[ $available_width -lt 24 ]] && available_width=24
|
||||
[[ $available_width -gt 35 ]] && available_width=35
|
||||
|
||||
# Truncate project name if needed
|
||||
local truncated_name=$(truncate_by_display_width "$project_name" "$available_width")
|
||||
local current_width=$(get_display_width "$truncated_name")
|
||||
local char_count=${#truncated_name}
|
||||
local padding=$((available_width - current_width))
|
||||
local printf_width=$((char_count + padding))
|
||||
|
||||
# Format: "project_name size | artifact_type"
|
||||
printf "%-*s %9s | %-13s" "$printf_width" "$truncated_name" "$size_str" "$artifact_type"
|
||||
}
|
||||
|
||||
# Build menu options - one line per artifact
|
||||
for item in "${safe_to_clean[@]}"; do
|
||||
local project_name=$(get_project_name "$item")
|
||||
local artifact_type=$(basename "$item")
|
||||
local size_kb=$(get_dir_size_kb "$item")
|
||||
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
||||
|
||||
# Check if recent
|
||||
local is_recent=false
|
||||
for recent_item in "${recently_modified[@]}"; do
|
||||
if [[ "$item" == "$recent_item" ]]; then
|
||||
is_recent=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
menu_options+=("$(format_purge_display "$project_name" "$artifact_type" "$size_human")")
|
||||
item_paths+=("$item")
|
||||
item_sizes+=("$size_kb")
|
||||
item_recent_flags+=("$is_recent")
|
||||
done
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}●${NC} Found ${#all_found_items[@]} artifacts (${#safe_to_clean[@]} older than $MIN_AGE_DAYS days)"
|
||||
# Set global vars for selector
|
||||
export PURGE_CATEGORY_SIZES=$(IFS=,; echo "${item_sizes[*]}")
|
||||
export PURGE_RECENT_CATEGORIES=$(IFS=,; echo "${item_recent_flags[*]}")
|
||||
|
||||
if [[ ${#recently_modified[@]} -gt 0 ]]; then
|
||||
echo -e "${YELLOW}${ICON_WARNING}${NC} Skipping ${#recently_modified[@]} recently modified items (active projects)"
|
||||
# Interactive selection (only if terminal is available)
|
||||
PURGE_SELECTION_RESULT=""
|
||||
if [[ -t 0 ]]; then
|
||||
if ! select_purge_categories "${menu_options[@]}"; then
|
||||
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Non-interactive: select all non-recent items
|
||||
for ((i = 0; i < ${#menu_options[@]}; i++)); do
|
||||
if [[ ${item_recent_flags[i]} != "true" ]]; then
|
||||
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
|
||||
PURGE_SELECTION_RESULT+="$i"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#safe_to_clean[@]} -eq 0 ]]; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} No old artifacts to clean."
|
||||
note_activity
|
||||
return
|
||||
if [[ -z "$PURGE_SELECTION_RESULT" ]]; then
|
||||
echo ""
|
||||
echo -e "${GRAY}No items selected${NC}"
|
||||
printf '\n'
|
||||
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Show total size estimate
|
||||
local total_size_mb=$((total_found_size / 1024))
|
||||
if [[ $total_size_mb -gt 0 ]]; then
|
||||
echo -e "${GRAY}Estimated space to reclaim: ~${total_size_mb} MB${NC}"
|
||||
fi
|
||||
# Clean selected items
|
||||
echo ""
|
||||
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
|
||||
|
||||
# Clean safe items
|
||||
for item in "${safe_to_clean[@]}"; do
|
||||
safe_clean "$item" "$(basename "$(dirname "$item")")/$(basename "$item")"
|
||||
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
||||
local cleaned_count=0
|
||||
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
local item_path="${item_paths[idx]}"
|
||||
local artifact_type=$(basename "$item_path")
|
||||
local project_name=$(get_project_name "$item_path")
|
||||
local size_kb="${item_sizes[idx]}"
|
||||
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
||||
|
||||
# Safety checks
|
||||
if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Show progress
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Cleaning $project_name/$artifact_type..."
|
||||
fi
|
||||
|
||||
# Clean the item
|
||||
if [[ -e "$item_path" ]]; then
|
||||
rm -rf "$item_path" 2> /dev/null || true
|
||||
|
||||
# Update stats
|
||||
if [[ ! -e "$item_path" ]]; then
|
||||
local current_total=$(cat "$stats_dir/purge_stats" 2>/dev/null || echo "0")
|
||||
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
|
||||
((cleaned_count++))
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
echo -e "${GREEN}✓${NC} $project_name - $artifact_type ${GREEN}($size_human)${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update count
|
||||
echo "$cleaned_count" > "$stats_dir/purge_count"
|
||||
|
||||
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
||||
}
|
||||
|
||||
@@ -366,8 +366,6 @@ manage_whitelist_categories() {
|
||||
local exit_code=$?
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Cancelled${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ select_apps_for_uninstall() {
|
||||
fi
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Cancelled"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
9
mole
9
mole
@@ -231,6 +231,7 @@ show_help() {
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize" "$NC" "Check and maintain system"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo analyze" "$NC" "Explore disk usage"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo status" "$NC" "Monitor system health"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo purge" "$NC" "Remove old project artifacts"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo touchid" "$NC" "Configure Touch ID for sudo"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo update" "$NC" "Update to latest version"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo remove" "$NC" "Remove Mole from system"
|
||||
@@ -242,10 +243,6 @@ show_help() {
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --force-rescan" "$NC" "Rescan apps and refresh cache"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items"
|
||||
echo
|
||||
printf "%s%s%s\n" "$BLUE" "ADVANCED" "$NC"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo purge" "$NC" "Remove old project artifacts"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project cleanup"
|
||||
echo
|
||||
printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs"
|
||||
echo
|
||||
@@ -501,8 +498,6 @@ remove_mole() {
|
||||
drain_pending_input # Clean up any escape sequence remnants
|
||||
case "$key" in
|
||||
$'\e')
|
||||
echo -e "${GRAY}Cancelled${NC}"
|
||||
echo ""
|
||||
exit 0
|
||||
;;
|
||||
"" | $'\n' | $'\r')
|
||||
@@ -510,8 +505,6 @@ remove_mole() {
|
||||
# Continue with removal
|
||||
;;
|
||||
*)
|
||||
echo -e "${GRAY}Cancelled${NC}"
|
||||
echo ""
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
270
tests/purge.bats
Normal file
270
tests/purge.bats
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for project artifact purge functionality
|
||||
# bin/purge.sh and lib/clean/project.sh
|
||||
|
||||
setup_file() {
|
||||
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
|
||||
export PROJECT_ROOT
|
||||
|
||||
ORIGINAL_HOME="${HOME:-}"
|
||||
export ORIGINAL_HOME
|
||||
|
||||
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")"
|
||||
export HOME
|
||||
|
||||
mkdir -p "$HOME"
|
||||
}
|
||||
|
||||
teardown_file() {
|
||||
rm -rf "$HOME"
|
||||
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
|
||||
export HOME="$ORIGINAL_HOME"
|
||||
fi
|
||||
}
|
||||
|
||||
setup() {
|
||||
# Create test project directories
|
||||
mkdir -p "$HOME/www"
|
||||
mkdir -p "$HOME/dev"
|
||||
mkdir -p "$HOME/.cache/mole"
|
||||
|
||||
# Clean any previous test artifacts
|
||||
rm -rf "$HOME/www"/* "$HOME/dev"/*
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# Safety Checks
|
||||
# =================================================================
|
||||
|
||||
@test "is_safe_project_artifact: rejects shallow paths (protection against accidents)" {
|
||||
# Should reject ~/www/node_modules (too shallow, depth < 1)
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
if is_safe_project_artifact '$HOME/www/node_modules' '$HOME/www'; then
|
||||
echo 'UNSAFE'
|
||||
else
|
||||
echo 'SAFE'
|
||||
fi
|
||||
")
|
||||
[[ "$result" == "SAFE" ]]
|
||||
}
|
||||
|
||||
@test "is_safe_project_artifact: allows proper project artifacts" {
|
||||
# Should allow ~/www/myproject/node_modules (depth >= 1)
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
if is_safe_project_artifact '$HOME/www/myproject/node_modules' '$HOME/www'; then
|
||||
echo 'ALLOWED'
|
||||
else
|
||||
echo 'BLOCKED'
|
||||
fi
|
||||
")
|
||||
[[ "$result" == "ALLOWED" ]]
|
||||
}
|
||||
|
||||
@test "is_safe_project_artifact: rejects non-absolute paths" {
|
||||
# Should reject relative paths
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
if is_safe_project_artifact 'relative/path/node_modules' '$HOME/www'; then
|
||||
echo 'UNSAFE'
|
||||
else
|
||||
echo 'SAFE'
|
||||
fi
|
||||
")
|
||||
[[ "$result" == "SAFE" ]]
|
||||
}
|
||||
|
||||
@test "is_safe_project_artifact: validates depth calculation" {
|
||||
# ~/www/project/subdir/node_modules should be allowed (depth = 2)
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
if is_safe_project_artifact '$HOME/www/project/subdir/node_modules' '$HOME/www'; then
|
||||
echo 'ALLOWED'
|
||||
else
|
||||
echo 'BLOCKED'
|
||||
fi
|
||||
")
|
||||
[[ "$result" == "ALLOWED" ]]
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# Nested Artifact Filtering
|
||||
# =================================================================
|
||||
|
||||
@test "filter_nested_artifacts: removes nested node_modules" {
|
||||
# Create nested structure:
|
||||
# ~/www/project/node_modules/package/node_modules
|
||||
mkdir -p "$HOME/www/project/node_modules/package/node_modules"
|
||||
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
printf '%s\n' '$HOME/www/project/node_modules' '$HOME/www/project/node_modules/package/node_modules' | \
|
||||
filter_nested_artifacts | wc -l | tr -d ' '
|
||||
")
|
||||
|
||||
# Should only keep the parent node_modules (nested one filtered out)
|
||||
[[ "$result" == "1" ]]
|
||||
}
|
||||
|
||||
@test "filter_nested_artifacts: keeps independent artifacts" {
|
||||
mkdir -p "$HOME/www/project1/node_modules"
|
||||
mkdir -p "$HOME/www/project2/target"
|
||||
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
printf '%s\n' '$HOME/www/project1/node_modules' '$HOME/www/project2/target' | \
|
||||
filter_nested_artifacts | wc -l | tr -d ' '
|
||||
")
|
||||
|
||||
# Should keep both (they're independent)
|
||||
[[ "$result" == "2" ]]
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# Recently Modified Detection
|
||||
# =================================================================
|
||||
|
||||
@test "is_recently_modified: detects recent projects" {
|
||||
mkdir -p "$HOME/www/project/node_modules"
|
||||
touch "$HOME/www/project/package.json" # Recently touched
|
||||
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
if is_recently_modified '$HOME/www/project/node_modules'; then
|
||||
echo 'RECENT'
|
||||
else
|
||||
echo 'OLD'
|
||||
fi
|
||||
")
|
||||
[[ "$result" == "RECENT" ]]
|
||||
}
|
||||
|
||||
@test "is_recently_modified: marks old projects correctly" {
|
||||
mkdir -p "$HOME/www/old-project/node_modules"
|
||||
mkdir -p "$HOME/www/old-project"
|
||||
|
||||
# Simulate old project (modified 30 days ago)
|
||||
# Note: This is hard to test reliably without mocking 'find'
|
||||
# Just verify the function can run without errors
|
||||
bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
is_recently_modified '$HOME/www/old-project/node_modules' || true
|
||||
"
|
||||
[ "$?" -eq 0 ] || [ "$?" -eq 1 ] # Allow both true/false, just check no crash
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# Artifact Detection
|
||||
# =================================================================
|
||||
|
||||
@test "purge targets are configured correctly" {
|
||||
# Verify PURGE_TARGETS array exists and contains expected values
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
echo \"\${PURGE_TARGETS[@]}\"
|
||||
")
|
||||
[[ "$result" == *"node_modules"* ]]
|
||||
[[ "$result" == *"target"* ]]
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# Size Calculation
|
||||
# =================================================================
|
||||
|
||||
@test "get_dir_size_kb: calculates directory size" {
|
||||
mkdir -p "$HOME/www/test-project/node_modules"
|
||||
# Create a file with known size (~1MB)
|
||||
dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null
|
||||
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
get_dir_size_kb '$HOME/www/test-project/node_modules'
|
||||
")
|
||||
|
||||
# Should be around 1024 KB (allow some filesystem overhead)
|
||||
[[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]]
|
||||
}
|
||||
|
||||
@test "get_dir_size_kb: handles non-existent paths gracefully" {
|
||||
result=$(bash -c "
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
get_dir_size_kb '$HOME/www/non-existent'
|
||||
")
|
||||
[[ "$result" == "0" ]]
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# Integration Tests (Non-Interactive)
|
||||
# =================================================================
|
||||
|
||||
@test "clean_project_artifacts: handles empty directory gracefully" {
|
||||
# No projects, should exit cleanly
|
||||
run bash -c "
|
||||
export HOME='$HOME'
|
||||
source '$PROJECT_ROOT/lib/core/common.sh'
|
||||
source '$PROJECT_ROOT/lib/clean/project.sh'
|
||||
clean_project_artifacts
|
||||
" < /dev/null
|
||||
|
||||
# Should succeed (exit code 0 or 2 for nothing to clean)
|
||||
[[ "$status" -eq 0 ]] || [[ "$status" -eq 2 ]]
|
||||
}
|
||||
|
||||
@test "clean_project_artifacts: scans and finds artifacts" {
|
||||
# Create test project with node_modules (make it big enough to detect)
|
||||
mkdir -p "$HOME/www/test-project/node_modules/package1"
|
||||
echo "test data" > "$HOME/www/test-project/node_modules/package1/index.js"
|
||||
|
||||
# Create parent directory timestamp old enough
|
||||
mkdir -p "$HOME/www/test-project"
|
||||
|
||||
# Run in non-interactive mode (with timeout to avoid hanging)
|
||||
run bash -c "
|
||||
export HOME='$HOME'
|
||||
timeout 5 '$PROJECT_ROOT/bin/purge.sh' 2>&1 < /dev/null || true
|
||||
"
|
||||
|
||||
# Should either scan successfully or exit gracefully
|
||||
# Check for expected outputs (scanning, completion, or nothing found)
|
||||
[[ "$output" =~ "Scanning" ]] ||
|
||||
[[ "$output" =~ "Purge complete" ]] ||
|
||||
[[ "$output" =~ "No old" ]] ||
|
||||
[[ "$output" =~ "Great" ]]
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# Command Line Interface
|
||||
# =================================================================
|
||||
|
||||
@test "mo purge: command exists and is executable" {
|
||||
[ -x "$PROJECT_ROOT/mole" ]
|
||||
[ -f "$PROJECT_ROOT/bin/purge.sh" ]
|
||||
}
|
||||
|
||||
@test "mo purge: shows in help text" {
|
||||
run env HOME="$HOME" "$PROJECT_ROOT/mole" --help
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"mo purge"* ]]
|
||||
}
|
||||
|
||||
@test "mo purge: accepts --debug flag" {
|
||||
# Just verify it doesn't crash with --debug
|
||||
run bash -c "
|
||||
export HOME='$HOME'
|
||||
timeout 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true
|
||||
"
|
||||
# Should not crash (any exit code is OK, we just want to verify it runs)
|
||||
true
|
||||
}
|
||||
|
||||
@test "mo purge: creates cache directory for stats" {
|
||||
# Run purge (will exit quickly in non-interactive with no projects)
|
||||
bash -c "
|
||||
export HOME='$HOME'
|
||||
timeout 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true
|
||||
"
|
||||
|
||||
# Cache directory should be created
|
||||
[ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ]
|
||||
}
|
||||
Reference in New Issue
Block a user