#!/usr/bin/env bash # Homebrew 智能升级脚本(失败隔离、选择性重试与性能优化版) set -euo pipefail prepend_path_once() { local dir="$1" [[ -d "$dir" ]] || return 0 case ":$PATH:" in *":$dir:"*) ;; *) PATH="$dir:$PATH" ;; esac } prepend_path_once "/usr/local/sbin" prepend_path_once "/opt/homebrew/sbin" export PATH # 禁止 Homebrew 在每个安装步骤后自动清理;统一在最后一步按缓存年龄清理。 export HOMEBREW_NO_INSTALL_CLEANUP=1 if [[ -t 1 ]]; then GREEN='\033[1;32m' YELLOW='\033[1;33m' BLUE='\033[1;34m' CYAN='\033[1;36m' NC='\033[0m' export HOMEBREW_COLOR=1 else GREEN='' YELLOW='' BLUE='' CYAN='' NC='' fi TERMINAL_WIDTH="" ACTIVE_LOG_FILE="" ACTIVE_CLEANUP_MARKER="" ACTIVE_CLEANUP_MANIFEST="" LOCK_ACQUIRED=0 LOCK_DIR="${HB_LOCK_DIR:-${TMPDIR:-/tmp}/brewup-$(id -u).lock}" FAILED_FORMULAE=() FAILED_CASKS=() usage() { cat <<'EOF' Usage: brew-upgrade-manager.sh [--width N] Options: --width N, --width=N Set output width (40-500). -h, --help Show this help. EOF } parse_args() { while (($# > 0)); do case "$1" in --width) [[ $# -ge 2 ]] || { echo "Error: --width requires a value." >&2; exit 2; } TERMINAL_WIDTH="$2" shift 2 ;; --width=*) TERMINAL_WIDTH="${1#*=}" shift ;; -h|--help) usage exit 0 ;; *) echo "Error: Unknown argument: $1" >&2 usage >&2 exit 2 ;; esac done } resolve_terminal_width() { if [[ -z "$TERMINAL_WIDTH" ]]; then TERMINAL_WIDTH="${HB_TERMINAL_WIDTH:-}" fi if [[ -z "$TERMINAL_WIDTH" ]] && [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then TERMINAL_WIDTH="$(tput cols 2>/dev/null || true)" fi [[ -n "$TERMINAL_WIDTH" ]] || TERMINAL_WIDTH=130 if [[ ! "$TERMINAL_WIDTH" =~ ^[0-9]+$ ]] || ((TERMINAL_WIDTH < 40 || TERMINAL_WIDTH > 500)); then echo "Error: terminal width must be an integer between 40 and 500." >&2 exit 2 fi export COLUMNS="$TERMINAL_WIDTH" } separator() { local i for ((i = 0; i < TERMINAL_WIDTH; i++)); do printf '=' done printf '\n' } print_header() { echo -e "${BLUE}$1${NC}" } is_enabled() { case "${1:-}" in 1|true|TRUE|yes|YES|on|ON) return 0 ;; *) return 1 ;; esac } # shellcheck disable=SC2329 # Invoked by EXIT trap. cleanup_runtime() { if [[ -n "${ACTIVE_LOG_FILE:-}" && -f "$ACTIVE_LOG_FILE" ]]; then rm -f "$ACTIVE_LOG_FILE" fi if [[ -n "${ACTIVE_CLEANUP_MARKER:-}" && -f "$ACTIVE_CLEANUP_MARKER" ]]; then rm -f "$ACTIVE_CLEANUP_MARKER" fi if [[ -n "${ACTIVE_CLEANUP_MANIFEST:-}" && -f "$ACTIVE_CLEANUP_MANIFEST" ]]; then rm -f "$ACTIVE_CLEANUP_MANIFEST" fi if ((LOCK_ACQUIRED)); then rm -f "$LOCK_DIR/pid" rmdir "$LOCK_DIR" 2>/dev/null || true fi } # shellcheck disable=SC2329 # Invoked by INT trap. on_interrupt() { exit 130 } # shellcheck disable=SC2329 # Invoked by TERM trap. on_terminate() { exit 143 } trap cleanup_runtime EXIT trap on_interrupt INT trap on_terminate TERM acquire_lock() { local existing_pid="" if mkdir "$LOCK_DIR" 2>/dev/null; then LOCK_ACQUIRED=1 printf '%s\n' "$$" > "$LOCK_DIR/pid" return 0 fi if [[ -r "$LOCK_DIR/pid" ]]; then IFS= read -r existing_pid < "$LOCK_DIR/pid" || true fi if [[ "$existing_pid" =~ ^[0-9]+$ ]] && kill -0 "$existing_pid" 2>/dev/null; then echo "Error: another brewup process is already running (PID ${existing_pid})." >&2 exit 75 fi rm -f "$LOCK_DIR/pid" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || { echo "Error: unable to remove stale lock directory: $LOCK_DIR" >&2 exit 75 } if ! mkdir "$LOCK_DIR" 2>/dev/null; then echo "Error: another brewup process acquired the lock." >&2 exit 75 fi LOCK_ACQUIRED=1 printf '%s\n' "$$" > "$LOCK_DIR/pid" } retry_attempts() { local attempts="${HB_RETRY_ATTEMPTS:-3}" if [[ ! "$attempts" =~ ^[1-9][0-9]*$ ]] || ((attempts > 10)); then attempts=3 fi printf '%s' "$attempts" } retry_delay_seconds() { local delay="${HB_RETRY_DELAY_SECONDS:-5}" if [[ ! "$delay" =~ ^[0-9]+$ ]] || ((delay > 300)); then delay=5 fi printf '%s' "$delay" } is_non_retryable_brew_error() { local log_file="$1" grep -Eiq \ 'Running Homebrew as root|Permission denied|Operation not permitted|No space left on device|checksum mismatch|SHA-?256 mismatch|certificate verify failed|SSL certificate problem|curl: \((3|23|26|60)\)|unknown (option|argument)|invalid (option|argument)' \ "$log_file" } is_retryable_brew_error() { local log_file="$1" grep -Eiq \ 'curl: \((5|6|7|18|28|35|52|55|56|92)\)|Transferred a partial file|Could not resolve host|timed out|timeout|connection (reset|refused|closed)|HTTP[^0-9]*(429|5[0-9]{2})|returned error: (429|5[0-9]{2})|temporarily unavailable|failed to download|download.*failed|RPC failed|early EOF|remote end hung up|unexpected disconnect' \ "$log_file" } run_brew_with_retries() { local label="$1" shift local attempts delay attempt status next_delay local pipeline_status=() attempts="$(retry_attempts)" delay="$(retry_delay_seconds)" ACTIVE_LOG_FILE="$(mktemp "${TMPDIR:-/tmp}/brewup-retry.XXXXXX.log")" for ((attempt = 1; attempt <= attempts; attempt++)); do if ((attempt > 1)); then echo -e "${YELLOW}Retrying ${label} (${attempt}/${attempts}) after ${delay}s...${NC}" sleep "$delay" next_delay=$((delay * 2)) ((next_delay > 300)) && next_delay=300 delay="$next_delay" fi : > "$ACTIVE_LOG_FILE" if brew "$@" 2>&1 | tee "$ACTIVE_LOG_FILE"; then status=0 else pipeline_status=("${PIPESTATUS[@]}") status="${pipeline_status[0]}" if ((${pipeline_status[1]:-0} != 0)); then echo "Error: failed to write retry log." >&2 status="${pipeline_status[1]}" fi fi if ((status == 0)); then rm -f "$ACTIVE_LOG_FILE" ACTIVE_LOG_FILE="" return 0 fi if is_non_retryable_brew_error "$ACTIVE_LOG_FILE"; then rm -f "$ACTIVE_LOG_FILE" ACTIVE_LOG_FILE="" return "$status" fi if ((attempt == attempts)) || ! is_retryable_brew_error "$ACTIVE_LOG_FILE"; then rm -f "$ACTIVE_LOG_FILE" ACTIVE_LOG_FILE="" return "$status" fi echo -e "${YELLOW}${label} failed with a retryable error.${NC}" done } get_outdated_formulae() { brew outdated --formula 2>/dev/null } get_outdated_casks() { brew outdated --cask --greedy 2>/dev/null } run_formula_upgrade() { local bulk_status=0 outdated_formulae remaining_formulae formula local failed_formulae=() brew upgrade --formula || bulk_status=$? if ((bulk_status == 0)); then return 0 fi echo -e "${YELLOW}Warning: Formula upgrade returned ${bulk_status}; checking remaining Formulae...${NC}" if ! outdated_formulae="$(get_outdated_formulae)"; then echo -e "${YELLOW}Warning: unable to verify outdated Formulae.${NC}" return "$bulk_status" fi if [[ -z "$outdated_formulae" ]]; then echo -e "${YELLOW}No outdated Formulae remain; treating the bulk error as recovered.${NC}" return 0 fi printf '%s\n' "$outdated_formulae" while IFS= read -r formula; do [[ -n "$formula" ]] || continue echo -e "${CYAN}Retrying Formula: ${formula}${NC}" if run_brew_with_retries "Formula ${formula}" upgrade --formula "$formula"; then : else failed_formulae+=("$formula") fi done <<< "$outdated_formulae" if ! remaining_formulae="$(get_outdated_formulae)"; then echo -e "${YELLOW}Warning: unable to verify Formulae after individual retries.${NC}" FAILED_FORMULAE=("${failed_formulae[@]}") return 1 fi if [[ -z "$remaining_formulae" ]]; then echo -e "${GREEN}All outdated Formulae were upgraded after isolated retries.${NC}" return 0 fi while IFS= read -r formula; do [[ -n "$formula" ]] && FAILED_FORMULAE+=("$formula") done <<< "$remaining_formulae" echo -e "${YELLOW}Formulae still outdated after retries:${NC}" printf '%s\n' "$remaining_formulae" return 1 } run_cask_upgrade() { local bulk_status=0 outdated_casks remaining_casks cask local bulk_args=(upgrade --cask --greedy) local failed_casks=() if is_enabled "${HB_CASK_FORCE:-0}"; then bulk_args+=(--force) echo -e "${YELLOW}HB_CASK_FORCE is enabled; existing Cask files may be overwritten.${NC}" fi brew "${bulk_args[@]}" || bulk_status=$? if ((bulk_status == 0)); then return 0 fi echo -e "${YELLOW}Warning: Cask upgrade returned ${bulk_status}; checking remaining Casks...${NC}" if ! outdated_casks="$(get_outdated_casks)"; then echo -e "${YELLOW}Warning: unable to verify outdated Casks.${NC}" return "$bulk_status" fi if [[ -z "$outdated_casks" ]]; then echo -e "${YELLOW}No outdated Casks remain; treating the bulk error as recovered.${NC}" return 0 fi printf '%s\n' "$outdated_casks" while IFS= read -r cask; do [[ -n "$cask" ]] || continue echo -e "${CYAN}Retrying Cask: ${cask} (without --force)${NC}" if run_brew_with_retries "Cask ${cask}" upgrade --cask --greedy "$cask"; then : else failed_casks+=("$cask") fi done <<< "$outdated_casks" if ! remaining_casks="$(get_outdated_casks)"; then echo -e "${YELLOW}Warning: unable to verify Casks after individual retries.${NC}" FAILED_CASKS=("${failed_casks[@]}") return 1 fi if [[ -z "$remaining_casks" ]]; then echo -e "${GREEN}All outdated Casks were upgraded after isolated retries.${NC}" return 0 fi while IFS= read -r cask; do [[ -n "$cask" ]] && FAILED_CASKS+=("$cask") done <<< "$remaining_casks" echo -e "${YELLOW}Casks still outdated after retries:${NC}" printf '%s\n' "$remaining_casks" return 1 } format_bytes() { local bytes="$1" unit=0 divisor=1 tenths local units=(B KiB MiB GiB TiB) while ((unit < 4 && bytes >= divisor * 1024)); do ((unit += 1)) ((divisor *= 1024)) done if ((unit == 0)); then printf '%d B' "$bytes" return fi tenths=$(((bytes * 10 + divisor / 2) / divisor)) printf '%d.%d %s' "$((tenths / 10))" "$((tenths % 10))" "${units[unit]}" } prune_cache_before_today() { local cache_dir cutoff_stamp cutoff_date file file_size relative_path local removed_count=0 removed_bytes=0 kept_count=0 kept_bytes=0 cleanup_failed=0 cache_dir="$(brew --cache)" if [[ ! -d "$cache_dir" || "$cache_dir" == "/" || "$cache_dir" == "$HOME" ]]; then echo "Error: Homebrew returned an unsafe cache path: $cache_dir" >&2 return 1 fi # macOS date -v 使用本地时区。以昨天 23:59:59 为边界,确保今天零点 # 之后生成的缓存全部保留,而不是使用滚动 24 小时窗口。 cutoff_stamp="$(date -v-1d '+%Y%m%d2359.59')" cutoff_date="$(date '+%Y-%m-%d')" ACTIVE_CLEANUP_MARKER="$(mktemp "${TMPDIR:-/tmp}/brewup-cleanup-cutoff.XXXXXX")" ACTIVE_CLEANUP_MANIFEST="$(mktemp "${TMPDIR:-/tmp}/brewup-cleanup-files.XXXXXX")" touch -t "$cutoff_stamp" "$ACTIVE_CLEANUP_MARKER" if ! find "$cache_dir" -mindepth 1 \( -type f -o -type l \) \ ! -newer "$ACTIVE_CLEANUP_MARKER" -print0 > "$ACTIVE_CLEANUP_MANIFEST"; then echo "Error: failed to enumerate Homebrew cache files to remove." >&2 return 1 fi echo "Removed cache files:" while IFS= read -r -d '' file; do if ! file_size="$(stat -f '%z' -- "$file" 2>/dev/null)"; then file_size=0 fi relative_path="${file#"$cache_dir"/}" if rm -f -- "$file"; then removed_count=$((removed_count + 1)) removed_bytes=$((removed_bytes + file_size)) printf ' %10s %q\n' "$(format_bytes "$file_size")" "$relative_path" else echo "Warning: failed to remove cache file: $file" >&2 cleanup_failed=1 fi done < "$ACTIVE_CLEANUP_MANIFEST" if ((removed_count == 0)); then echo " (none)" fi printf 'Removed %d Homebrew cache file(s) dated before %s (%s total).\n' \ "$removed_count" "$cutoff_date" "$(format_bytes "$removed_bytes")" if ! find "$cache_dir" -mindepth 1 \( -type f -o -type l \) \ -newer "$ACTIVE_CLEANUP_MARKER" -print0 > "$ACTIVE_CLEANUP_MANIFEST"; then echo "Error: failed to enumerate today's retained Homebrew cache files." >&2 return 1 fi echo "Retained cache files dated today or later:" while IFS= read -r -d '' file; do if ! file_size="$(stat -f '%z' -- "$file" 2>/dev/null)"; then file_size=0 fi relative_path="${file#"$cache_dir"/}" kept_count=$((kept_count + 1)) kept_bytes=$((kept_bytes + file_size)) printf ' %10s %q\n' "$(format_bytes "$file_size")" "$relative_path" done < "$ACTIVE_CLEANUP_MANIFEST" if ((kept_count == 0)); then echo " (none)" fi printf "Retained %d Homebrew cache file(s) dated %s or later (%s total).\n" \ "$kept_count" "$cutoff_date" "$(format_bytes "$kept_bytes")" rm -f "$ACTIVE_CLEANUP_MARKER" ACTIVE_CLEANUP_MARKER="" rm -f "$ACTIVE_CLEANUP_MANIFEST" ACTIVE_CLEANUP_MANIFEST="" return "$cleanup_failed" } run_cleanup() { local cleanup_days="${HB_CLEANUP_DAYS:-}" if is_enabled "${HB_SKIP_CLEANUP:-0}"; then echo -e "${YELLOW}Cleanup skipped because HB_SKIP_CLEANUP is enabled.${NC}" return 0 fi if is_enabled "${HB_CLEANUP_ALL:-0}" || [[ "$cleanup_days" == "all" ]]; then brew cleanup --prune=all return fi if [[ -n "$cleanup_days" ]]; then if [[ ! "$cleanup_days" =~ ^[0-9]+$ ]] || ((cleanup_days > 3650)); then echo "Error: HB_CLEANUP_DAYS must be an integer between 0 and 3650." >&2 return 2 fi brew cleanup --prune="$cleanup_days" return fi # 普通 brew cleanup 可能按版本状态删除今天生成的下载,无法严格保留 # 今日缓存。这里只让 Homebrew 清理 prefix 中的失效链接,再按自然日 # 精确清理缓存文件。 brew cleanup --prune-prefix prune_cache_before_today } print_summary() { local overall_status="$1" separator if ((overall_status == 0)); then echo -e "${GREEN}All operations completed successfully.${NC}" else echo -e "${YELLOW}Homebrew upgrade completed with unresolved failures.${NC}" if ((${#FAILED_FORMULAE[@]} > 0)); then printf 'Failed Formulae: %s\n' "${FAILED_FORMULAE[*]}" fi if ((${#FAILED_CASKS[@]} > 0)); then printf 'Failed Casks: %s\n' "${FAILED_CASKS[*]}" fi fi separator } parse_args "$@" resolve_terminal_width command -v brew >/dev/null 2>&1 || { echo "Error: Homebrew is not installed or not in PATH." >&2; exit 127; } command -v tee >/dev/null 2>&1 || { echo "Error: tee is required." >&2; exit 127; } acquire_lock separator print_header "Step 1: Updating Homebrew repositories (brew update -v)" run_brew_with_retries "Homebrew repository update" update -v separator printf '\n' separator print_header "Step 2: Performing health check (brew doctor)" if is_enabled "${HB_SKIP_DOCTOR:-0}"; then echo -e "${YELLOW}brew doctor skipped because HB_SKIP_DOCTOR is enabled.${NC}" elif ! brew doctor; then echo -e "${YELLOW}Warning: 'brew doctor' detected issues. Manual review is recommended.${NC}" else echo "Homebrew environment is in good health." fi separator printf '\n' separator print_header "Step 3: Upgrading Formulae and Casks with failure isolation" overall_status=0 echo -e "\n${CYAN}>>> [1/2] Upgrading CLI Formulae...${NC}" if ! run_formula_upgrade; then overall_status=1 fi echo -e "\n${CYAN}>>> [2/2] Upgrading GUI Casks...${NC}" if ! run_cask_upgrade; then overall_status=1 fi separator printf '\n' separator print_header "Step 4: Cleaning cache dated before today (keeping today's downloads)" if ! run_cleanup; then overall_status=1 fi separator printf '\n' print_summary "$overall_status" exit "$overall_status"