1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 19:40:07 +00:00

Add dry-run support across destructive commands (#516)

* chore: update contributors [skip ci]

* Add dry-run support across destructive commands

Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).

* test(purge): keep dev-compatible purge coverage

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
This commit is contained in:
陳德生
2026-03-01 20:03:22 +08:00
committed by GitHub
parent adcd98096a
commit 05446e0847
18 changed files with 1021 additions and 684 deletions

View File

@@ -1367,6 +1367,7 @@ clean_project_artifacts() {
echo ""
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0
local dry_run_mode="${MOLE_DRY_RUN:-0}"
for idx in "${selected_indices[@]}"; do
local item_path="${item_paths[idx]}"
local artifact_type=$(basename "$item_path")
@@ -1388,7 +1389,7 @@ clean_project_artifacts() {
fi
if [[ -e "$item_path" ]]; then
safe_remove "$item_path" true
if [[ ! -e "$item_path" ]]; then
if [[ "$dry_run_mode" == "1" || ! -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=$((cleaned_count + 1))
@@ -1396,7 +1397,11 @@ clean_project_artifacts() {
fi
if [[ -t 1 ]]; then
stop_inline_spinner
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
if [[ "$dry_run_mode" == "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
fi
fi
done
# Update count

View File

@@ -1419,6 +1419,11 @@ force_kill_app() {
local app_name="$1"
local app_path="${2:-""}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would terminate running app: $app_name"
return 0
fi
# Get the executable name from bundle if app_path is provided
local exec_name=""
if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then

View File

@@ -18,6 +18,7 @@ show_installer_help() {
echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)."
echo ""
echo "Options:"
echo " --dry-run Preview installer cleanup without making changes"
echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message"
}
@@ -45,6 +46,7 @@ show_touchid_help() {
echo " status Show current Touch ID status"
echo ""
echo "Options:"
echo " --dry-run Preview Touch ID changes without modifying sudo config"
echo " -h, --help Show this help message"
echo ""
echo "If no command is provided, an interactive menu is shown."
@@ -56,6 +58,7 @@ show_uninstall_help() {
echo "Interactively remove applications and their leftover files."
echo ""
echo "Options:"
echo " --dry-run Preview app uninstallation without making changes"
echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message"
}

View File

@@ -11,6 +11,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Batch uninstall with a single confirmation.
get_lsregister_path() {
echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
}
is_uninstall_dry_run() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# High-performance sensitive data detection (pure Bash, no subprocess)
# Faster than grep for batch operations, especially when processing many apps
has_sensitive_data() {
@@ -77,6 +85,11 @@ stop_launch_services() {
local bundle_id="$1"
local has_system_files="${2:-false}"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id"
return 0
fi
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
# Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app)
@@ -152,6 +165,11 @@ remove_login_item() {
local app_name="$1"
local bundle_id="$2"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}"
return 0
fi
# Skip if no identifiers provided
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0
@@ -201,7 +219,12 @@ remove_file_list() {
safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
else
if [[ "$use_sudo" == "true" ]]; then
safe_sudo_remove "$file" && ((++count)) || true
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would sudo remove: $file"
((++count))
else
safe_sudo_remove "$file" && ((++count)) || true
fi
else
safe_remove "$file" true && ((++count)) || true
fi
@@ -437,7 +460,7 @@ batch_uninstall_applications() {
export MOLE_UNINSTALL_MODE=1
# Request sudo if needed.
if [[ ${#sudo_apps[@]} -gt 0 ]]; then
if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
echo ""
@@ -547,12 +570,18 @@ batch_uninstall_applications() {
fi
fi
else
local ret=0
safe_sudo_remove "$app_path" || ret=$?
if [[ $ret -ne 0 ]]; then
local diagnosis
diagnosis=$(diagnose_removal_failure "$ret" "$app_name")
IFS='|' read -r reason suggestion <<< "$diagnosis"
if is_uninstall_dry_run; then
if ! safe_remove "$app_path" true; then
reason="dry-run path validation failed"
fi
else
local ret=0
safe_sudo_remove "$app_path" || ret=$?
if [[ $ret -ne 0 ]]; then
local diagnosis
diagnosis=$(diagnose_removal_failure "$ret" "$app_name")
IFS='|' read -r reason suggestion <<< "$diagnosis"
fi
fi
fi
else
@@ -583,10 +612,14 @@ batch_uninstall_applications() {
remove_file_list "$system_all" "true" > /dev/null
fi
# Clean up macOS defaults (preference domains).
# Defaults writes are side effects that should never run in dry-run mode.
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would clear defaults domain: $bundle_id"
else
if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true
fi
fi
# ByHost preferences (machine-specific).
@@ -644,8 +677,15 @@ batch_uninstall_applications() {
local success_text="app"
[[ $success_count -gt 1 ]] && success_text="apps"
local success_line="Removed ${success_count} ${success_text}"
if is_uninstall_dry_run; then
success_line="Would remove ${success_count} ${success_text}"
fi
if [[ -n "$freed_display" ]]; then
success_line+=", freed ${GREEN}${freed_display}${NC}"
if is_uninstall_dry_run; then
success_line+=", would free ${GREEN}${freed_display}${NC}"
else
success_line+=", freed ${GREEN}${freed_display}${NC}"
fi
fi
# Format app list with max 3 per line.
@@ -730,24 +770,48 @@ batch_uninstall_applications() {
if [[ "$summary_status" == "warn" ]]; then
title="Uninstall incomplete"
fi
if is_uninstall_dry_run; then
title="Uninstall dry run complete"
fi
echo ""
print_summary_block "$title" "${summary_details[@]}"
printf '\n'
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
# Kick off LaunchServices rebuild in background immediately after summary.
# The caller shows a 3s "Press Enter" prompt, giving the rebuild time to finish
# before the user returns to the app list — fixes stale Spotlight entries (#490).
(
refresh_launch_services_after_uninstall 2> /dev/null || true
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
) > /dev/null 2>&1 &
# Auto-run brew autoremove if Homebrew casks were uninstalled
if [[ $brew_apps_removed -gt 0 ]]; then
if is_uninstall_dry_run; then
log_info "[DRY RUN] Would run brew autoremove"
else
# Show spinner while checking for orphaned dependencies
if [[ -t 1 ]]; then
start_inline_spinner "Checking brew dependencies..."
fi
local autoremove_output removed_count
autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true
removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true)
removed_count=${removed_count:-0}
if [[ -t 1 ]]; then
stop_inline_spinner
fi
if [[ $removed_count -gt 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies"
echo ""
fi
fi
fi
# brew autoremove can be slow — run in background so the prompt returns quickly.
if [[ $brew_apps_removed -gt 0 ]]; then
(HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true) &
# Clean up Dock entries for uninstalled apps.
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
if is_uninstall_dry_run; then
log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries"
else
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
refresh_launch_services_after_uninstall 2> /dev/null || true
fi
fi
_cleanup_sudo_keepalive

View File

@@ -168,6 +168,11 @@ brew_uninstall_cask() {
local cask_name="$1"
local app_path="${2:-}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would brew uninstall --cask --zap $cask_name"
return 0
fi
is_homebrew_available || return 1
[[ -z "$cask_name" ]] && return 1