diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index a3931c5..464fedf 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -530,15 +530,35 @@ batch_uninstall_applications() { if brew_uninstall_cask "$cask_name" "$app_path"; then used_brew_successfully=true else - # Fallback to manual removal if brew fails - if [[ "$needs_sudo" == true ]]; then - if ! safe_sudo_remove "$app_path"; then - reason="brew failed, manual removal failed" + # Only fall back to manual app removal when Homebrew no longer + # tracks the cask. Otherwise we would recreate the mismatch + # where brew still reports the app as installed after Mole + # removes the bundle manually. + local cask_state=2 + if command -v is_brew_cask_installed > /dev/null 2>&1; then + if is_brew_cask_installed "$cask_name"; then + cask_state=0 + else + cask_state=$? fi + fi + + if [[ $cask_state -eq 1 ]]; then + if [[ "$needs_sudo" == true ]]; then + if ! safe_sudo_remove "$app_path"; then + reason="brew cleanup incomplete, manual removal failed" + fi + else + if ! safe_remove "$app_path" true; then + reason="brew cleanup incomplete, manual removal failed" + fi + fi + elif [[ $cask_state -eq 0 ]]; then + reason="brew uninstall failed, package still installed" + suggestion="Run brew uninstall --cask --zap $cask_name" else - if ! safe_remove "$app_path" true; then - reason="brew failed, manual removal failed" - fi + reason="brew uninstall failed, package state unknown" + suggestion="Run brew uninstall --cask --zap $cask_name" fi fi elif [[ "$needs_sudo" == true ]]; then diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 012ca53..5856a52 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -35,6 +35,21 @@ is_homebrew_available() { command -v brew > /dev/null 2>&1 } +# Check whether a cask is still recorded as installed in Homebrew. +# Exit codes: +# 0 - cask is installed +# 1 - cask is not installed +# 2 - install state could not be determined +is_brew_cask_installed() { + local cask_name="$1" + [[ -n "$cask_name" ]] || return 2 + is_homebrew_available || return 2 + + local cask_list + cask_list=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null) || return 2 + grep -qxF "$cask_name" <<< "$cask_list" +} + # Extract cask token from a Caskroom path # Args: $1 - path (must be inside Caskroom) # Prints: cask token to stdout @@ -211,7 +226,12 @@ brew_uninstall_cask() { # Verify removal (only if not timed out) local cask_gone=true app_gone=true - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false + if is_brew_cask_installed "$cask_name"; then + cask_gone=false + else + local cask_state=$? + [[ $cask_state -eq 1 ]] || cask_gone=false + fi [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false # Success: uninstall worked and both are gone, or already uninstalled diff --git a/tests/brew_uninstall.bats b/tests/brew_uninstall.bats index ae49f75..f10d3b1 100644 --- a/tests/brew_uninstall.bats +++ b/tests/brew_uninstall.bats @@ -228,6 +228,122 @@ EOF [[ "$output" != *"Checking brew dependencies"* ]] } +@test "batch_uninstall_applications keeps brew-managed app intact when brew uninstall fails" { + local app_bundle="$HOME/Applications/BrewBroken.app" + mkdir -p "$app_bundle" + + 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/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +calculate_total_size() { echo "0"; } +has_sensitive_data() { return 1; } +decode_file_list() { return 0; } +remove_file_list() { :; } +run_with_timeout() { shift; "$@"; } + +safe_remove() { + echo "SAFE_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +safe_sudo_remove() { + echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +get_brew_cask_name() { echo "brew-broken-cask"; return 0; } +brew_uninstall_cask() { return 1; } +is_brew_cask_installed() { return 0; } + +selected_apps=("0|$HOME/Applications/BrewBroken.app|BrewBroken|com.example.brewbroken|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 || true + +[[ -d "$HOME/Applications/BrewBroken.app" ]] +[[ ! -f "$HOME/remove.log" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications finishes cleanup after brew removes cask record" { + local app_bundle="$HOME/Applications/BrewCleanup.app" + mkdir -p "$app_bundle" + + 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/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +calculate_total_size() { echo "0"; } +has_sensitive_data() { return 1; } +decode_file_list() { return 0; } +remove_file_list() { :; } +run_with_timeout() { shift; "$@"; } + +safe_remove() { + echo "SAFE_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +safe_sudo_remove() { + echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +get_brew_cask_name() { echo "brew-cleanup-cask"; return 0; } +brew_uninstall_cask() { return 1; } +is_brew_cask_installed() { return 1; } + +selected_apps=("0|$HOME/Applications/BrewCleanup.app|BrewCleanup|com.example.brewcleanup|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/BrewCleanup.app" ]] +grep -q "SAFE_REMOVE:$HOME/Applications/BrewCleanup.app" "$HOME/remove.log" +EOF + + [ "$status" -eq 0 ] +} + @test "brew_uninstall_cask does not trigger extra sudo pre-auth" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail