1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 19:44:44 +00:00
Files
Mole/lib/uninstall/brew.sh
Andrei Murariu e8f46a7a1c feat: Homebrew uninstall with --zap flag (#397)
Uninstalling mole with Zap will automatically remove also the directories that brew creates for it. -> More complete uninstallation

Also updated the test about uninstalling
2026-02-02 11:26:58 +08:00

228 lines
7.6 KiB
Bash

#!/bin/bash
# Mole - Homebrew Cask Uninstallation Support
# Detects Homebrew-managed casks via Caskroom linkage and uninstalls them via brew
set -euo pipefail
# Prevent multiple sourcing
if [[ -n "${MOLE_BREW_UNINSTALL_LOADED:-}" ]]; then
return 0
fi
readonly MOLE_BREW_UNINSTALL_LOADED=1
# Resolve a path to its absolute real path (follows symlinks)
# Args: $1 - path to resolve
# Returns: Absolute resolved path, or empty string on failure
resolve_path() {
local p="$1"
[[ -e "$p" ]] || return 1
# macOS 12.3+ and Linux have realpath
if realpath "$p" 2> /dev/null; then
return 0
fi
# Fallback: use cd -P to resolve directory, then append basename
local dir base
dir=$(cd -P "$(dirname "$p")" 2> /dev/null && pwd) || return 1
base=$(basename "$p")
echo "$dir/$base"
}
# Check if Homebrew is installed and accessible
# Returns: 0 if brew is available, 1 otherwise
is_homebrew_available() {
command -v brew > /dev/null 2>&1
}
# Extract cask token from a Caskroom path
# Args: $1 - path (must be inside Caskroom)
# Prints: cask token to stdout
# Returns: 0 if valid token extracted, 1 otherwise
_extract_cask_token_from_path() {
local path="$1"
# Check if path is inside Caskroom
case "$path" in
/opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;;
*) return 1 ;;
esac
# Extract token from path: /opt/homebrew/Caskroom/<token>/<version>/...
local token
token="${path#*/Caskroom/}" # Remove everything up to and including Caskroom/
token="${token%%/*}" # Take only the first path component
# Validate token looks like a valid cask name (lowercase alphanumeric with hyphens)
if [[ -n "$token" && "$token" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
echo "$token"
return 0
fi
return 1
}
# Stage 1: Deterministic detection via fully resolved path
# Fast, no false positives - follows all symlinks
_detect_cask_via_resolved_path() {
local app_path="$1"
local resolved
if resolved=$(resolve_path "$app_path") && [[ -n "$resolved" ]]; then
_extract_cask_token_from_path "$resolved" && return 0
fi
return 1
}
# Stage 2: Search Caskroom by app bundle name using find
# Catches apps where the .app in /Applications doesn't link to Caskroom
# Only succeeds if exactly one cask matches (avoids wrong uninstall)
_detect_cask_via_caskroom_search() {
local app_bundle_name="$1"
[[ -z "$app_bundle_name" ]] && return 1
local -a tokens=()
local room match token
for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do
[[ -d "$room" ]] || continue
while IFS= read -r match; do
[[ -n "$match" ]] || continue
token=$(_extract_cask_token_from_path "$match" 2> /dev/null) || continue
[[ -n "$token" ]] && tokens+=("$token")
done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null)
done
# Need at least one token
((${#tokens[@]} > 0)) || return 1
# Deduplicate and check count
local -a uniq
IFS=$'\n' read -r -d '' -a uniq < <(printf '%s\n' "${tokens[@]}" | sort -u && printf '\0') || true
# Only succeed if exactly one unique token found and it's installed
if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then
HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1
echo "${uniq[0]}"
return 0
fi
return 1
}
# Stage 3: Check if app_path is a direct symlink to Caskroom
_detect_cask_via_symlink_check() {
local app_path="$1"
[[ -L "$app_path" ]] || return 1
local target
target=$(readlink "$app_path" 2> /dev/null) || return 1
_extract_cask_token_from_path "$target"
}
# Stage 4: Query brew list --cask and verify with brew info (slowest fallback)
_detect_cask_via_brew_list() {
local app_path="$1"
local app_bundle_name="$2"
local app_name_lower
app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]')
local cask_name
cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1
# Verify this cask actually owns this app path
HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1
echo "$cask_name"
}
# Get Homebrew cask name for an app
# Uses multi-stage detection (fast to slow, deterministic to heuristic):
# 1. Resolve symlinks fully, check if path is in Caskroom (fast, deterministic)
# 2. Search Caskroom by app bundle name using find
# 3. Check if app is a direct symlink to Caskroom
# 4. Query brew list --cask and verify with brew info (slowest)
#
# Args: $1 - app_path
# Prints: cask token to stdout if brew-managed
# Returns: 0 if Homebrew-managed, 1 otherwise
get_brew_cask_name() {
local app_path="$1"
[[ -z "$app_path" || ! -e "$app_path" ]] && return 1
is_homebrew_available || return 1
local app_bundle_name
app_bundle_name=$(basename "$app_path")
# Try each detection method in order (fast to slow)
_detect_cask_via_resolved_path "$app_path" && return 0
_detect_cask_via_caskroom_search "$app_bundle_name" && return 0
_detect_cask_via_symlink_check "$app_path" && return 0
_detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0
return 1
}
# Uninstall a Homebrew cask and verify removal
# Args: $1 - cask_name, $2 - app_path (optional, for verification)
# Returns: 0 on success, 1 on failure
brew_uninstall_cask() {
local cask_name="$1"
local app_path="${2:-}"
is_homebrew_available || return 1
[[ -z "$cask_name" ]] && return 1
debug_log "Attempting brew uninstall --cask --zap $cask_name"
# Ensure we have sudo access if needed, to prevent brew from hanging on password prompt
if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then
if ! sudo -n true 2> /dev/null; then
sudo -v
fi
fi
local uninstall_ok=false
local brew_exit=0
# Calculate timeout based on app size (large apps need more time)
local timeout=300 # Default 5 minutes
if [[ -n "$app_path" && -d "$app_path" ]]; then
local size_gb=$(($(get_path_size_kb "$app_path") / 1048576))
if [[ $size_gb -gt 15 ]]; then
timeout=900 # 15 minutes for very large apps (Xcode, Adobe, etc.)
elif [[ $size_gb -gt 5 ]]; then
timeout=600 # 10 minutes for large apps
fi
debug_log "App size: ${size_gb}GB, timeout: ${timeout}s"
fi
# Run with timeout to prevent hangs from problematic cask scripts
local brew_exit=0
if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \
run_with_timeout "$timeout" brew uninstall --cask --zap "$cask_name" 2>&1; then
uninstall_ok=true
else
brew_exit=$?
debug_log "brew uninstall timeout or failed with exit code: $brew_exit"
# Exit code 124 indicates timeout from run_with_timeout
# On timeout, fail immediately without verification to avoid inconsistent state
if [[ $brew_exit -eq 124 ]]; then
debug_log "brew uninstall timed out after ${timeout}s, returning failure"
return 1
fi
fi
# 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
[[ -n "$app_path" && -e "$app_path" ]] && app_gone=false
# Success: uninstall worked and both are gone, or already uninstalled
if $cask_gone && $app_gone; then
debug_log "Successfully uninstalled cask '$cask_name'"
return 0
fi
debug_log "brew uninstall failed: cask_gone=$cask_gone app_gone=$app_gone"
return 1
}