1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 09:46:44 +00:00

Code support format detection

This commit is contained in:
Tw93
2025-10-12 20:49:10 +08:00
parent d111f18cea
commit cf821cdc4b
25 changed files with 1482 additions and 1152 deletions

25
.editorconfig Normal file
View File

@@ -0,0 +1,25 @@
# EditorConfig for Mole project
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{sh,bash}]
indent_style = space
indent_size = 4
# shfmt will use these settings automatically
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

View File

@@ -1,4 +1,4 @@
name: Mole Tests
name: Tests
on:
push:
@@ -13,10 +13,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install bats-core
run: |
brew update
brew install bats-core
- name: Install tools
run: brew install bats-core shfmt shellcheck
- name: Run test suite
- name: Check formatting
run: ./scripts/format.sh --check
- name: Run shellcheck
run: |
find . -type f \( -name "*.sh" -o -name "mole" \) \
! -path "./.git/*" \
-exec shellcheck -S warning {} +
- name: Run tests
run: tests/run.sh

54
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,54 @@
# Contributing to Mole
## Setup
```bash
# Install tools
brew install shfmt shellcheck bats-core
# Install git hooks (optional)
./scripts/install-hooks.sh
```
## Development
```bash
# Format code
./scripts/format.sh
# Run tests
./tests/run.sh
# Check quality
shellcheck -S warning mole bin/*.sh lib/*.sh
```
## Git Hooks
Pre-commit hook will auto-format your code. Install with:
```bash
./scripts/install-hooks.sh
```
Skip if needed: `git commit --no-verify`
## Code Style
- Bash 3.2+ compatible
- 4 spaces indent
- Use `set -euo pipefail`
- Quote all variables
- BSD commands not GNU
Config: `.editorconfig` and `.shellcheckrc`
## Pull Requests
1. Fork and create branch
2. Make changes
3. Format: `./scripts/format.sh`
4. Test: `./tests/run.sh`
5. Commit and push
6. Open PR
CI will check formatting, lint, and run tests.

View File

@@ -78,7 +78,7 @@ scan_large_files() {
size=$(stat -f%z "$file" 2> /dev/null || echo "0")
echo "$size|$file"
fi
done < <(mdfind -onlyin "$target_path" "kMDItemFSSize > $MIN_LARGE_FILE_SIZE" 2>/dev/null) | \
done < <(mdfind -onlyin "$target_path" "kMDItemFSSize > $MIN_LARGE_FILE_SIZE" 2> /dev/null) |
sort -t'|' -k1 -rn > "$output_file"
}
@@ -99,7 +99,7 @@ scan_medium_files() {
echo "$size|$file"
fi
done < <(mdfind -onlyin "$target_path" \
"kMDItemFSSize > $MIN_MEDIUM_FILE_SIZE && kMDItemFSSize < $MIN_LARGE_FILE_SIZE" 2>/dev/null) | \
"kMDItemFSSize > $MIN_MEDIUM_FILE_SIZE && kMDItemFSSize < $MIN_LARGE_FILE_SIZE" 2> /dev/null) |
sort -t'|' -k1 -rn > "$output_file"
}
@@ -112,16 +112,16 @@ scan_directories() {
# Check if we can use parallel processing
if command -v xargs &> /dev/null && [[ $depth -eq 1 ]]; then
# Fast parallel scan for depth 1
find "$target_path" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | \
xargs -0 -P 4 -I {} du -sk {} 2>/dev/null | \
sort -rn | \
find "$target_path" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null |
xargs -0 -P 4 -I {} du -sk {} 2> /dev/null |
sort -rn |
while IFS=$'\t' read -r size path; do
echo "$((size * 1024))|$path"
done > "$output_file"
else
# Standard du scan
du -d "$depth" -k "$target_path" 2>/dev/null | \
sort -rn | \
du -d "$depth" -k "$target_path" 2> /dev/null |
sort -rn |
while IFS=$'\t' read -r size path; do
# Skip if path is the target itself at depth > 0
if [[ "$path" != "$target_path" ]]; then
@@ -651,7 +651,7 @@ display_cleanup_suggestions_compact() {
"kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2> /dev/null | wc -l | tr -d ' ')
if [[ $dmg_count -gt 0 ]]; then
local dmg_size=$(mdfind -onlyin "$CURRENT_PATH" \
"kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | \
"kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2> /dev/null |
xargs stat -f%z 2> /dev/null | awk '{sum+=$1} END {print sum}')
local dmg_human
dmg_human=$(bytes_to_human "$dmg_size")
@@ -678,7 +678,7 @@ display_cleanup_suggestions_compact() {
# Check for duplicates in current path
if command -v mdfind &> /dev/null; then
local dup_count=$(mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \
local dup_count=$(mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2> /dev/null |
xargs -I {} stat -f "%z" {} 2> /dev/null | sort | uniq -d | wc -l | tr -d ' ')
if [[ $dup_count -gt 5 ]]; then
[[ -z "$top_suggestion" ]] && top_suggestion="$dup_count potential duplicate files detected"
@@ -771,8 +771,8 @@ display_cleanup_suggestions() {
# Check for duplicate files (by size, quick heuristic)
if command -v mdfind &> /dev/null; then
local temp_dup="$TEMP_PREFIX.dup_check"
mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \
xargs -I {} stat -f "%z" {} 2>/dev/null | \
mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2> /dev/null |
xargs -I {} stat -f "%z" {} 2> /dev/null |
sort | uniq -d | wc -l | tr -d ' ' > "$temp_dup" 2> /dev/null || echo "0" > "$temp_dup"
local dup_count
dup_count=$(cat "$temp_dup" 2> /dev/null || echo "0")
@@ -842,19 +842,23 @@ get_file_info() {
case "$ext" in
dmg | iso | pkg | zip | tar | gz | rar | 7z)
badge="$BADGE_BUNDLE" ; type="Bundle"
badge="$BADGE_BUNDLE"
type="Bundle"
;;
mov | mp4 | avi | mkv | webm | jpg | jpeg | png | gif | heic)
badge="$BADGE_MEDIA" ; type="Media"
badge="$BADGE_MEDIA"
type="Media"
;;
pdf | key | ppt | pptx)
type="Document"
;;
log)
badge="$BADGE_LOG" ; type="Log"
badge="$BADGE_LOG"
type="Log"
;;
app)
badge="$BADGE_APP" ; type="App"
badge="$BADGE_APP"
type="App"
;;
esac
@@ -1114,7 +1118,7 @@ display_recent_large_files() {
# Find files created in last 30 days, larger than 100MB
mdfind -onlyin "$CURRENT_PATH" \
"kMDItemFSSize > 100000000 && kMDItemContentCreationDate >= \$time.today(-30)" 2>/dev/null | \
"kMDItemFSSize > 100000000 && kMDItemContentCreationDate >= \$time.today(-30)" 2> /dev/null |
while IFS= read -r file; do
if [[ -f "$file" ]]; then
local size
@@ -1162,7 +1166,7 @@ get_subdirectories() {
local target="$1"
local temp_file="$2"
find "$target" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | \
find "$target" -mindepth 1 -maxdepth 1 -type d 2> /dev/null |
while IFS= read -r dir; do
local size
size=$(du -sk "$dir" 2> /dev/null | cut -f1)
@@ -1396,13 +1400,13 @@ scan_directory_contents_fast() {
fi
# Ultra-fast file scanning - batch stat for maximum speed
find "$dir_path" -mindepth 1 -maxdepth 1 -type f -print0 2>/dev/null | \
find "$dir_path" -mindepth 1 -maxdepth 1 -type f -print0 2> /dev/null |
xargs -0 -n 20 -P "$num_jobs" stat -f "%z|file|%N" 2> /dev/null > "$temp_files" &
local file_pid=$!
# Smart directory scanning with aggressive optimization
# Strategy: Fast estimation first, accurate on-demand
find "$dir_path" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | \
find "$dir_path" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null |
xargs -0 -n 1 -P "$num_jobs" sh -c '
dir="$1"
size=""
@@ -1498,7 +1502,7 @@ scan_directory_contents_fast() {
# Combine and sort - only keep top items
# Ensure we handle empty files gracefully
> "$output_file"
true > "$output_file"
if [[ -f "$temp_dirs" ]] || [[ -f "$temp_files" ]]; then
cat "$temp_dirs" "$temp_files" 2> /dev/null | sort -t'|' -k1 -rn | head -"$max_items" > "$output_file" || true
fi
@@ -1531,7 +1535,7 @@ combine_initial_scan_results() {
local temp_large="$TEMP_PREFIX.large"
local temp_dirs="$TEMP_PREFIX.dirs"
> "$output_file"
true > "$output_file"
# Add directories
if [[ -f "$temp_dirs" ]]; then
@@ -1760,7 +1764,7 @@ interactive_drill_down() {
# Use || true to prevent exit on scan failure
scan_directory_contents_fast "$current_path" "$temp_items" 50 true || {
# Scan failed - create empty result file
> "$temp_items"
true > "$temp_items"
}
# Save to cache for next time (only if not empty)
@@ -1886,8 +1890,10 @@ interactive_drill_down() {
local badge="$BADGE_FILE" color="${NC}"
if [[ "$type" == "dir" ]]; then
badge="$BADGE_DIR" color="${BLUE}"
if [[ $size -gt 10737418240 ]]; then color="${RED}"
elif [[ $size -gt 1073741824 ]]; then color="${YELLOW}"
if [[ $size -gt 10737418240 ]]; then
color="${RED}"
elif [[ $size -gt 1073741824 ]]; then
color="${YELLOW}"
fi
else
local ext="${name##*.}"

View File

@@ -229,7 +229,7 @@ safe_clean() {
if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
for w in "${WHITELIST_PATTERNS[@]}"; do
# Match both exact path and glob pattern
if [[ "$path" == "$w" ]] || [[ "$path" == $w ]]; then
if [[ "$path" == "$w" ]] || [[ "$path" == "$w" ]]; then
skip=true
((skipped_count++))
break
@@ -253,15 +253,18 @@ safe_clean() {
# Show progress indicator for potentially slow operations
if [[ ${#existing_paths[@]} -gt 3 ]]; then
if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking items with whitelist safety..."; fi
local temp_dir=$(create_temp_dir)
local temp_dir
temp_dir=$(create_temp_dir)
# Parallel processing (bash 3.2 compatible)
local -a pids=()
local idx=0
for path in "${existing_paths[@]}"; do
(
local size=$(du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0")
local count=$(find "$path" -type f 2>/dev/null | wc -l | tr -d ' ')
local size
size=$(du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
local count
count=$(find "$path" -type f 2> /dev/null | wc -l | tr -d ' ')
# Use index + PID for unique filename
local tmp_file="$temp_dir/result_${idx}.$$"
echo "$size $count" > "$tmp_file"
@@ -304,8 +307,10 @@ safe_clean() {
if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking items with whitelist safety..."; fi
for path in "${existing_paths[@]}"; do
local size_bytes=$(du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0")
local count=$(find "$path" -type f 2>/dev/null | wc -l | tr -d ' ')
local size_bytes
size_bytes=$(du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
local count
count=$(find "$path" -type f 2> /dev/null | wc -l | tr -d ' ')
if [[ "$count" -gt 0 && "$size_bytes" -gt 0 ]]; then
if [[ "$DRY_RUN" != "true" ]]; then
@@ -319,7 +324,10 @@ safe_clean() {
fi
# Clear progress / stop spinner before showing result
if [[ -t 1 ]]; then stop_inline_spinner; echo -ne "\r\033[K"; fi
if [[ -t 1 ]]; then
stop_inline_spinner
echo -ne "\r\033[K"
fi
if [[ $removed_any -eq 1 ]]; then
# Convert KB to bytes for bytes_to_human()
@@ -526,7 +534,6 @@ perform_cleanup() {
safe_clean ~/Downloads/*.part "Incomplete downloads (partial)"
end_section
# ===== 3. macOS System Caches =====
start_section "macOS system caches"
safe_clean ~/Library/Saved\ Application\ State/* "Saved application states"
@@ -542,7 +549,6 @@ perform_cleanup() {
safe_clean ~/Library/Application\ Support/CloudDocs/session/db/* "iCloud session cache"
end_section
# ===== 4. Sandboxed App Caches =====
start_section "Sandboxed app caches"
# Clean specific high-usage apps first for better user feedback
@@ -553,7 +559,6 @@ perform_cleanup() {
safe_clean ~/Library/Containers/*/Data/Library/Caches/* "Sandboxed app caches"
end_section
# ===== 5. Browsers =====
start_section "Browser cleanup"
# Safari (cache only, NOT local storage or databases to preserve login states)
@@ -577,7 +582,6 @@ perform_cleanup() {
safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache"
end_section
# ===== 6. Cloud Storage =====
start_section "Cloud storage caches"
# Only cache files, not sync state or login credentials
@@ -590,7 +594,6 @@ perform_cleanup() {
safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache"
end_section
# ===== 7. Office Applications =====
start_section "Office applications"
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
@@ -603,7 +606,6 @@ perform_cleanup() {
safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache"
end_section
# ===== 8. Developer tools =====
start_section "Developer tools"
# Node.js ecosystem
@@ -676,7 +678,8 @@ perform_cleanup() {
safe_clean /usr/local/var/homebrew/locks/* "Homebrew lock files (Intel)"
if command -v brew > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then
clean_tool_cache "Homebrew cleanup" brew cleanup
# Use -s (scrub cache) for faster cleanup, --prune=all removes old versions
MOLE_CMD_TIMEOUT=300 clean_tool_cache "Homebrew cleanup" brew cleanup -s --prune=all
else
echo -e " ${YELLOW}${NC} Homebrew (would cleanup)"
fi
@@ -818,7 +821,6 @@ perform_cleanup() {
end_section
# ===== 10. Applications =====
start_section "Applications"
@@ -983,7 +985,6 @@ perform_cleanup() {
end_section
# ===== 11. Virtualization Tools =====
start_section "Virtualization tools"
safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache"
@@ -992,7 +993,6 @@ perform_cleanup() {
safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files"
end_section
# ===== 12. Application Support logs cleanup =====
start_section "Application Support logs"
@@ -1022,7 +1022,6 @@ perform_cleanup() {
end_section
# ===== 13. Orphaned app data cleanup =====
# Deep cleanup of leftover files from uninstalled apps
#
@@ -1525,7 +1524,6 @@ perform_cleanup() {
printf '\n'
}
main() {
# Parse args (only dry-run and help for minimal impact)
for arg in "$@"; do

View File

@@ -59,7 +59,6 @@ fi
selected_apps=() # Global array for app selection
declare -a apps_data=()
declare -a selection_state=()
current_line=0
total_items=0
files_cleaned=0
total_size_cleaned=0
@@ -108,8 +107,10 @@ scan_applications() {
# Quick count of current apps (system + user directories)
local current_app_count
current_app_count=$(
(find /Applications -name "*.app" -maxdepth 1 2>/dev/null;
find ~/Applications -name "*.app" -maxdepth 1 2>/dev/null) | wc -l | tr -d ' '
(
find /Applications -name "*.app" -maxdepth 1 2> /dev/null
find ~/Applications -name "*.app" -maxdepth 1 2> /dev/null
) | wc -l | tr -d ' '
)
# Check if cache is valid unless explicitly disabled
@@ -347,7 +348,10 @@ scan_applications() {
fi
# Sort by last used (oldest first) and cache the result
sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { rm -f "$temp_file"; return 1; }
sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || {
rm -f "$temp_file"
return 1
}
rm -f "$temp_file"
# Update cache with app count metadata
@@ -461,12 +465,13 @@ uninstall_applications() {
done <<< "$system_files"
fi
local size_display
if [[ $total_kb -gt 1048576 ]]; then # > 1GB
local size_display=$(echo "$total_kb" | awk '{printf "%.2fGB", $1/1024/1024}')
size_display=$(echo "$total_kb" | awk '{printf "%.2fGB", $1/1024/1024}')
elif [[ $total_kb -gt 1024 ]]; then # > 1MB
local size_display=$(echo "$total_kb" | awk '{printf "%.1fMB", $1/1024}')
size_display=$(echo "$total_kb" | awk '{printf "%.1fMB", $1/1024}')
else
local size_display="${total_kb}KB"
size_display="${total_kb}KB"
fi
echo -e " ${BLUE}Total size: $size_display${NC}"
@@ -521,12 +526,13 @@ uninstall_applications() {
echo -e "${PURPLE}${ICON_ARROW} Uninstallation Summary${NC}"
if [[ $total_size_freed -gt 0 ]]; then
local freed_display
if [[ $total_size_freed -gt 1048576 ]]; then # > 1GB
local freed_display=$(echo "$total_size_freed" | awk '{printf "%.2fGB", $1/1024/1024}')
freed_display=$(echo "$total_size_freed" | awk '{printf "%.2fGB", $1/1024/1024}')
elif [[ $total_size_freed -gt 1024 ]]; then # > 1MB
local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fMB", $1/1024}')
freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fMB", $1/1024}')
else
local freed_display="${total_size_freed}KB"
freed_display="${total_size_freed}KB"
fi
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Freed $freed_display of disk space"
@@ -634,7 +640,9 @@ main() {
clear
local selection_count=${#selected_apps[@]}
if [[ $selection_count -eq 0 ]]; then
echo "No apps selected"; rm -f "$apps_file"; return 0
echo "No apps selected"
rm -f "$apps_file"
return 0
fi
# Show selected apps, max 3 per line
echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):"

View File

@@ -13,14 +13,28 @@ NC='\033[0m'
# Simple spinner
_SPINNER_PID=""
start_line_spinner() {
local msg="$1"; [[ ! -t 1 ]] && { echo -e "${BLUE}|${NC} $msg"; return; }
local chars="${MO_SPINNER_CHARS:-|/-\\}"; [[ -z "$chars" ]] && chars='|/-\\'
local msg="$1"
[[ ! -t 1 ]] && {
echo -e "${BLUE}|${NC} $msg"
return
}
local chars="${MO_SPINNER_CHARS:-|/-\\}"
[[ -z "$chars" ]] && chars='|/-\\'
local i=0
( while true; do c="${chars:$((i % ${#chars})):1}"; printf "\r${BLUE}%s${NC} %s" "$c" "$msg"; ((i++)); sleep 0.12; done ) &
(while true; do
c="${chars:$((i % ${#chars})):1}"
printf "\r${BLUE}%s${NC} %s" "$c" "$msg"
((i++))
sleep 0.12
done) &
_SPINNER_PID=$!
}
stop_line_spinner() { if [[ -n "$_SPINNER_PID" ]]; then kill "$_SPINNER_PID" 2>/dev/null || true; wait "$_SPINNER_PID" 2>/dev/null || true; _SPINNER_PID=""; printf "\r\033[K"; fi; }
stop_line_spinner() { if [[ -n "$_SPINNER_PID" ]]; then
kill "$_SPINNER_PID" 2> /dev/null || true
wait "$_SPINNER_PID" 2> /dev/null || true
_SPINNER_PID=""
printf "\r\033[K"
fi; }
# Verbosity (0 = quiet, 1 = verbose)
VERBOSE=1
@@ -457,7 +471,9 @@ uninstall_mole() {
echo " $CONFIG_DIR"
else
echo ""
read -p "Remove configuration directory $CONFIG_DIR? (y/N): " -n 1 -r; echo ""; if [[ $REPLY =~ ^[Yy]$ ]]; then
read -p "Remove configuration directory $CONFIG_DIR? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
rm -rf "$CONFIG_DIR"
log_success "Removed configuration"
else
@@ -571,9 +587,21 @@ perform_update() {
# Update with minimal output (suppress info/success, show errors only)
local old_verbose=$VERBOSE
VERBOSE=0
create_directories || { VERBOSE=$old_verbose; log_error "Failed to create directories"; exit 1; }
install_files || { VERBOSE=$old_verbose; log_error "Failed to install files"; exit 1; }
verify_installation || { VERBOSE=$old_verbose; log_error "Failed to verify installation"; exit 1; }
create_directories || {
VERBOSE=$old_verbose
log_error "Failed to create directories"
exit 1
}
install_files || {
VERBOSE=$old_verbose
log_error "Failed to install files"
exit 1
}
verify_installation || {
VERBOSE=$old_verbose
log_error "Failed to verify installation"
exit 1
}
setup_path
VERBOSE=$old_verbose

View File

@@ -129,7 +129,11 @@ batch_uninstall_applications() {
return 1
fi
fi
(while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null) &
(while true; do
sudo -n true
sleep 60
kill -0 "$$" || exit
done 2> /dev/null) &
sudo_keepalive_pid=$!
fi

View File

@@ -349,7 +349,6 @@ get_directory_size_bytes() {
du -sk "$path" 2> /dev/null | cut -f1 | awk '{print $1 * 1024}' || echo "0"
}
# Permission checks
check_sudo() {
if ! sudo -n true 2> /dev/null; then
@@ -467,10 +466,6 @@ load_config() {
MOLE_MAX_LOG_SIZE="${MOLE_MAX_LOG_SIZE:-1048576}"
}
# Initialize configuration on sourcing
load_config
@@ -570,7 +565,6 @@ stop_spinner() {
# User Interaction - Confirmation Dialogs
# ============================================================================
# ============================================================================
# Temporary File Management
# ============================================================================
@@ -677,7 +671,8 @@ parallel_execute() {
# Usage: with_spinner "Message" cmd arg...
# Set MOLE_SPINNER_PREFIX=" " for indented spinner (e.g., in clean context)
with_spinner() {
local msg="$1"; shift || true
local msg="$1"
shift || true
local timeout="${MOLE_CMD_TIMEOUT:-180}" # Default 3min timeout
if [[ -t 1 ]]; then
@@ -727,7 +722,8 @@ with_spinner() {
# ============================================================================
# clean_tool_cache "Label" command...
clean_tool_cache() {
local label="$1"; shift || true
local label="$1"
shift || true
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${NC} $label (would clean)"
return 0
@@ -782,13 +778,21 @@ confirm_prompt() {
echo -n "$message (Enter=OK / ESC q=Cancel): "
IFS= read -r -s -n1 _key || _key=""
case "$_key" in
$'\e'|q|Q) echo ""; return 1 ;;
""|$'\n'|$'\r'|y|Y) echo ""; return 0 ;;
*) echo ""; return 1 ;;
$'\e' | q | Q)
echo ""
return 1
;;
"" | $'\n' | $'\r' | y | Y)
echo ""
return 0
;;
*)
echo ""
return 1
;;
esac
}
# Get optimal parallel job count based on CPU cores
# =========================================================================
@@ -796,7 +800,8 @@ confirm_prompt() {
# =========================================================================
bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))"; }
print_space_stat() {
local freed_kb="$1"; shift || true
local freed_kb="$1"
shift || true
local current_free
current_free=$(get_free_space)
local human
@@ -810,8 +815,18 @@ print_space_stat() {
register_temp_file() { MOLE_TEMP_FILES+=("$1"); }
register_temp_dir() { MOLE_TEMP_DIRS+=("$1"); }
mktemp_file() { local f; f=$(mktemp) || return 1; register_temp_file "$f"; echo "$f"; }
mktemp_dir() { local d; d=$(mktemp -d) || return 1; register_temp_dir "$d"; echo "$d"; }
mktemp_file() {
local f
f=$(mktemp) || return 1
register_temp_file "$f"
echo "$f"
}
mktemp_dir() {
local d
d=$(mktemp -d) || return 1
register_temp_dir "$d"
echo "$d"
}
# =========================================================================
# Uninstall helper abstractions
@@ -955,7 +970,8 @@ map_uninstall_reason() {
batch_safe_clean() {
# Usage: batch_safe_clean "Label" path1 path2 ...
local label="$1"; shift || true
local label="$1"
shift || true
local -a paths=("$@")
if [[ ${#paths[@]} -eq 0 ]]; then return 0; fi
safe_clean "${paths[@]}" "$label"
@@ -1381,7 +1397,6 @@ readonly DATA_PROTECTED_BUNDLES=(
"org.sparkle-project.Sparkle" # Sparkle (update framework)
)
# Legacy function - preserved for backward compatibility
# Use should_protect_from_uninstall() or should_protect_data() instead
readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}")
@@ -1390,7 +1405,7 @@ should_preserve_bundle() {
for pattern in "${PRESERVED_BUNDLE_PATTERNS[@]}"; do
# Use case for safer glob matching
case "$bundle_id" in
$pattern) return 0 ;;
"$pattern") return 0 ;;
esac
done
return 1
@@ -1402,7 +1417,7 @@ should_protect_from_uninstall() {
for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do
# Use case for safer glob matching
case "$bundle_id" in
$pattern) return 0 ;;
"$pattern") return 0 ;;
esac
done
return 1
@@ -1415,7 +1430,7 @@ should_protect_data() {
for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do
# Use case for safer glob matching
case "$bundle_id" in
$pattern) return 0 ;;
"$pattern") return 0 ;;
esac
done
return 1
@@ -1677,7 +1692,8 @@ calculate_total_size() {
while IFS= read -r file; do
if [[ -n "$file" && -e "$file" ]]; then
local size_kb=$(du -sk "$file" 2>/dev/null | awk '{print $1}' || echo "0")
local size_kb
size_kb=$(du -sk "$file" 2> /dev/null | awk '{print $1}' || echo "0")
((total_kb += size_kb))
fi
done <<< "$files"

View File

@@ -114,7 +114,6 @@ patterns_equivalent() {
return 1
}
load_whitelist() {
local -a patterns=()
@@ -163,14 +162,13 @@ is_whitelisted() {
if [[ "$check_pattern" == "$existing_expanded" ]]; then
return 0
fi
if [[ "$check_pattern" == $existing_expanded ]]; then
if [[ "$check_pattern" == "$existing_expanded" ]]; then
return 0
fi
done
return 1
}
manage_whitelist() {
manage_whitelist_categories
}
@@ -286,7 +284,6 @@ manage_whitelist_categories() {
printf '\n'
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
manage_whitelist
fi

27
mole
View File

@@ -28,7 +28,7 @@ MOLE_TAGLINE="can dig deep to clean your Mac."
# Get latest version from remote repository
get_latest_version() {
curl -fsSL --connect-timeout 2 --max-time 3 -H "Cache-Control: no-cache" \
"https://raw.githubusercontent.com/tw93/mole/main/mole" 2>/dev/null | \
"https://raw.githubusercontent.com/tw93/mole/main/mole" 2> /dev/null |
grep '^VERSION=' | head -1 | sed 's/VERSION="\(.*\)"/\1/'
}
@@ -180,7 +180,10 @@ update_mole() {
local installer_url="https://raw.githubusercontent.com/tw93/mole/main/install.sh"
local tmp_installer
tmp_installer="$(mktemp_file)" || { log_error "Update failed"; exit 1; }
tmp_installer="$(mktemp_file)" || {
log_error "Update failed"
exit 1
}
# Download installer with progress
if command -v curl > /dev/null 2>&1; then
@@ -318,7 +321,9 @@ remove_mole() {
printf '\n'
# Check if anything to remove
if [[ "$is_homebrew" == "false" && ${#manual_installs[@]:-0} -eq 0 && ${#alias_installs[@]:-0} -eq 0 ]]; then
local manual_count=${#manual_installs[@]}
local alias_count=${#alias_installs[@]}
if [[ "$is_homebrew" == "false" && ${manual_count:-0} -eq 0 && ${alias_count:-0} -eq 0 ]]; then
printf '%s\n\n' "${YELLOW}No Mole installation detected${NC}"
exit 0
fi
@@ -362,14 +367,14 @@ remove_mole() {
fi
fi
# Remove manual installations (silent)
if [[ ${#manual_installs[@]:-0} -gt 0 ]]; then
if [[ ${manual_count:-0} -gt 0 ]]; then
for install in "${manual_installs[@]}"; do
if [[ -f "$install" ]]; then
rm -f "$install" 2> /dev/null || has_error=true
fi
done
fi
if [[ ${#alias_installs[@]} -gt 0 ]]; then
if [[ ${alias_count:-0} -gt 0 ]]; then
for alias in "${alias_installs[@]}"; do
if [[ -f "$alias" ]]; then
rm -f "$alias" 2> /dev/null || true
@@ -509,7 +514,11 @@ interactive_main_menu() {
;;
2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;;
3) exec "$SCRIPT_DIR/bin/analyze.sh" ;;
4) clear; show_help; exit 0 ;;
4)
clear
show_help
exit 0
;;
5) cleanup_and_exit ;;
esac
;;
@@ -522,7 +531,11 @@ interactive_main_menu() {
;;
2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;;
3) exec "$SCRIPT_DIR/bin/analyze.sh" ;;
4) clear; show_help; exit 0 ;;
4)
clear
show_help
exit 0
;;
5) cleanup_and_exit ;;
esac
;;

60
scripts/format.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/bin/bash
# Format all shell scripts in the Mole project
#
# Usage:
# ./scripts/format.sh # Format all scripts
# ./scripts/format.sh --check # Check only, don't modify
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CHECK_ONLY=false
# Parse arguments
if [[ "${1:-}" == "--check" ]]; then
CHECK_ONLY=true
elif [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat << 'EOF'
Usage: ./scripts/format.sh [--check]
Format shell scripts using shfmt.
Options:
--check Check formatting without modifying files
--help Show this help
Install: brew install shfmt
EOF
exit 0
fi
# Check if shfmt is installed
if ! command -v shfmt > /dev/null 2>&1; then
echo "Error: shfmt not installed"
echo "Install: brew install shfmt"
exit 1
fi
# Find all shell scripts
cd "$PROJECT_ROOT"
# shfmt options: -i 4 (4 spaces), -ci (indent switch cases), -sr (space after redirect)
if [[ "$CHECK_ONLY" == "true" ]]; then
echo "Checking formatting..."
if shfmt -i 4 -ci -sr -d . > /dev/null 2>&1; then
echo "✓ All scripts properly formatted"
exit 0
else
echo "✗ Some scripts need formatting:"
shfmt -i 4 -ci -sr -d .
echo ""
echo "Run './scripts/format.sh' to fix"
exit 1
fi
else
echo "Formatting scripts..."
shfmt -i 4 -ci -sr -w .
echo "✓ Done"
fi

44
scripts/install-hooks.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
# Install git hooks for Mole project
#
# Usage:
# ./scripts/install-hooks.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
cd "$PROJECT_ROOT"
# Check if this is a git repository
if [ ! -d ".git" ]; then
echo "Error: Not a git repository"
exit 1
fi
echo -e "${BLUE}Installing git hooks...${NC}"
# Install pre-commit hook
if [ -f ".git/hooks/pre-commit" ]; then
echo "Pre-commit hook already exists, creating backup..."
mv .git/hooks/pre-commit .git/hooks/pre-commit.backup
fi
ln -s ../../scripts/pre-commit.sh .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo -e "${GREEN}✓ Pre-commit hook installed${NC}"
echo ""
echo "The hook will:"
echo " • Auto-format shell scripts before commit"
echo " • Run shellcheck on changed files"
echo " • Show warnings but won't block commits"
echo ""
echo "To uninstall:"
echo " rm .git/hooks/pre-commit"
echo ""

67
scripts/pre-commit.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
# Git pre-commit hook for Mole
# Automatically formats shell scripts before commit
#
# Installation:
# ln -s ../../scripts/pre-commit.sh .git/hooks/pre-commit
# chmod +x .git/hooks/pre-commit
#
# Or use the install script:
# ./scripts/install-hooks.sh
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Only check shell files that are staged
STAGED_SH_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.sh$|^mole$' || true)
if [ -z "$STAGED_SH_FILES" ]; then
exit 0
fi
echo -e "${YELLOW}Running pre-commit checks on shell files...${NC}"
# Check if shfmt is installed
if ! command -v shfmt &> /dev/null; then
echo -e "${RED}shfmt is not installed. Install with: brew install shfmt${NC}"
exit 1
fi
# Check if shellcheck is installed
if ! command -v shellcheck &> /dev/null; then
echo -e "${RED}shellcheck is not installed. Install with: brew install shellcheck${NC}"
exit 1
fi
NEEDS_FORMAT=0
# Check formatting
for file in $STAGED_SH_FILES; do
if ! shfmt -i 4 -ci -sr -d "$file" > /dev/null 2>&1; then
echo -e "${YELLOW}Formatting $file...${NC}"
shfmt -i 4 -ci -sr -w "$file"
git add "$file"
NEEDS_FORMAT=1
fi
done
# Run shellcheck
for file in $STAGED_SH_FILES; do
if ! shellcheck -S warning "$file" > /dev/null 2>&1; then
echo -e "${YELLOW}ShellCheck warnings in $file:${NC}"
shellcheck -S warning "$file"
echo -e "${YELLOW}Continuing with commit (warnings are non-critical)...${NC}"
fi
done
if [ $NEEDS_FORMAT -eq 1 ]; then
echo -e "${GREEN}✓ Files formatted and re-staged${NC}"
fi
echo -e "${GREEN}✓ Pre-commit checks passed${NC}"
exit 0

View File

@@ -80,7 +80,8 @@ teardown() {
}
@test "bytes_to_human converts byte counts into readable units" {
output="$(HOME="$HOME" bash --noprofile --norc <<'EOF'
output="$(
HOME="$HOME" bash --noprofile --norc << 'EOF'
source "$PROJECT_ROOT/lib/common.sh"
bytes_to_human 512
bytes_to_human 2048

View File

@@ -39,7 +39,8 @@ create_app_artifacts() {
@test "find_app_files discovers user-level leftovers" {
create_app_artifacts
result="$(HOME="$HOME" bash --noprofile --norc <<'EOF'
result="$(
HOME="$HOME" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/common.sh"
find_app_files "com.example.TestApp" "TestApp"
@@ -58,7 +59,8 @@ EOF
dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 > /dev/null 2>&1
dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 > /dev/null 2>&1
result="$(HOME="$HOME" bash --noprofile --norc <<'EOF'
result="$(
HOME="$HOME" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/common.sh"
files="$(printf '%s\n%s\n' "$HOME/sized/file1" "$HOME/sized/file2")"