diff --git a/bin/clean.sh b/bin/clean.sh index 4549454..b36b231 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -129,6 +129,7 @@ PROJECT_ARTIFACT_HINT_EXAMPLES=() PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0 PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false +declare -a DRY_RUN_SEEN_IDENTITIES=() # shellcheck disable=SC2329 note_activity() { @@ -137,6 +138,20 @@ note_activity() { fi } +# shellcheck disable=SC2329 +register_dry_run_cleanup_target() { + local path="$1" + local identity + identity=$(mole_path_identity "$path") + + if [[ ${#DRY_RUN_SEEN_IDENTITIES[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${DRY_RUN_SEEN_IDENTITIES[@]}"; then + return 1 + fi + + DRY_RUN_SEEN_IDENTITIES+=("$identity") + return 0 +} + CLEANUP_DONE=false # shellcheck disable=SC2329 cleanup() { @@ -380,7 +395,12 @@ safe_clean() { log_operation "clean" "SKIPPED" "$path" "whitelist" fi [[ "$skip" == "true" ]] && continue - [[ -e "$path" ]] && existing_paths+=("$path") + if [[ -e "$path" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + register_dry_run_cleanup_target "$path" || continue + fi + existing_paths+=("$path") + fi done if [[ "$show_scan_feedback" == "true" ]]; then @@ -705,6 +725,7 @@ start_cleanup() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="clean" log_operation_session_start "clean" + DRY_RUN_SEEN_IDENTITIES=() if [[ -t 1 ]]; then printf '\033[2J\033[H' diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index 463996b..abda06c 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -117,6 +117,8 @@ project_cache_has_indicators() { # Discover candidate project roots without scanning the whole home directory. discover_project_cache_roots() { local -a roots=() + local -a unique_roots=() + local -a seen_identities=() local root for root in "${MOLE_PURGE_DEFAULT_SEARCH_PATHS[@]}"; do @@ -147,7 +149,18 @@ discover_project_cache_roots() { [[ ${#roots[@]} -eq 0 ]] && return 0 - printf '%s\n' "${roots[@]}" | LC_ALL=C sort -u + for root in "${roots[@]}"; do + local identity + identity=$(mole_path_identity "$root") + if [[ ${#seen_identities[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${seen_identities[@]}"; then + continue + fi + + seen_identities+=("$identity") + unique_roots+=("$root") + done + + [[ ${#unique_roots[@]} -gt 0 ]] && printf '%s\n' "${unique_roots[@]}" } # Scan a project root for supported build caches while pruning heavy subtrees. diff --git a/lib/core/common.sh b/lib/core/common.sh index 38f7640..1211685 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -27,6 +27,45 @@ if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then source "$_MOLE_CORE_DIR/sudo.sh" fi +# Normalize a path for comparisons while preserving root. +mole_normalize_path() { + local path="$1" + local normalized="${path%/}" + [[ -n "$normalized" ]] && printf '%s\n' "$normalized" || printf '%s\n' "$path" +} + +# Return a stable identity for an existing path. Prefer dev+inode so aliased +# paths on case-insensitive filesystems or symlinks collapse to one identity. +mole_path_identity() { + local path="$1" + local normalized + normalized=$(mole_normalize_path "$path") + + if [[ -e "$normalized" || -L "$normalized" ]]; then + if command -v stat > /dev/null 2>&1; then + local fs_id="" + fs_id=$(stat -L -f '%d:%i' "$normalized" 2> /dev/null || stat -f '%d:%i' "$normalized" 2> /dev/null || true) + if [[ "$fs_id" =~ ^[0-9]+:[0-9]+$ ]]; then + printf 'inode:%s\n' "$fs_id" + return 0 + fi + fi + fi + + printf 'path:%s\n' "$normalized" +} + +mole_identity_in_list() { + local needle="$1" + shift + + local existing + for existing in "$@"; do + [[ "$existing" == "$needle" ]] && return 0 + done + return 1 +} + # Update via Homebrew update_via_homebrew() { local current_version="$1" diff --git a/tests/clean_core.bats b/tests/clean_core.bats index 8373378..127b96c 100644 --- a/tests/clean_core.bats +++ b/tests/clean_core.bats @@ -125,6 +125,18 @@ PLIST [ -f "$HOME/Library/LaunchAgents/com.example.stale.plist" ] } +@test "mo clean --dry-run does not export duplicate targets across sections" { + mkdir -p "$HOME/Library/Application Support/Code/CachedData" + echo "cache" > "$HOME/Library/Application Support/Code/CachedData/data.bin" + + run env HOME="$HOME" MOLE_TEST_MODE=0 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + + run grep -c "Application Support/Code/CachedData" "$HOME/.config/mole/clean-list.txt" + [ "$status" -eq 0 ] + [ "$output" -eq 1 ] +} + @test "mo clean honors whitelist entries" { mkdir -p "$HOME/Library/Caches/WhitelistedApp" echo "keep me" > "$HOME/Library/Caches/WhitelistedApp/data.tmp" diff --git a/tests/clean_system_caches.bats b/tests/clean_system_caches.bats index fbd57e7..b81ed42 100644 --- a/tests/clean_system_caches.bats +++ b/tests/clean_system_caches.bats @@ -238,6 +238,25 @@ EOF rm -rf "$HOME/go" } +@test "discover_project_cache_roots dedupes aliased roots by filesystem identity" { + mkdir -p "$HOME/code/demo/.dart_tool" + touch "$HOME/code/demo/pubspec.yaml" + mkdir -p "$HOME/.config/mole" + ln -s "$HOME/code" "$HOME/Code" + printf '%s\n' "$HOME/Code" > "$HOME/.config/mole/purge_paths" + + 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" +roots=$(discover_project_cache_roots) +printf '%s\n' "$roots" +printf 'COUNT=%s\n' "$(printf '%s\n' "$roots" | sed '/^$/d' | wc -l | tr -d ' ')" +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=1"* ]] +} + @test "clean_project_caches skips stalled root scans" { mkdir -p "$HOME/.config/mole" mkdir -p "$HOME/SlowProjects/app"