Files
script/homebrew/brew-upgrade-manager.sh
T
orion 1f221fd2fc feat(homebrew): 添加彩色列表打印和改进升级管理功能
- 新增 print_colored_list 函数用于彩色化列表输出
- 在 brew 命令中禁用 HOMEBREW_COLOR 环境变量
- 添加 run_brew_doctor 函数处理 brew doctor 输出并着色
- 添加初始公式和应用更新前的状态检查与显示
- 改进缓存清理功能,区分下载文件和内部文件
- 更新清理结果显示,仅显示下载文件并统计隐藏文件数量
- 使用 run_brew_doctor 替代原始 brew doctor 调用
- 优化升级流程中的状态提示信息显示
2026-07-18 13:53:32 +08:00

686 lines
21 KiB
Bash

#!/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
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
CYAN='\033[36m'
NC='\033[0m'
export HOMEBREW_COLOR=1
else
RED=''
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 ]] || { print_error "--width requires a value."; exit 2; }
TERMINAL_WIDTH="$2"
shift 2
;;
--width=*)
TERMINAL_WIDTH="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
*)
print_error "Unknown argument: $1"
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
print_error "terminal width must be an integer between 40 and 500."
exit 2
fi
export COLUMNS="$TERMINAL_WIDTH"
}
separator() {
local i
printf '%b' "$BLUE"
for ((i = 0; i < TERMINAL_WIDTH; i++)); do
printf '='
done
printf '%b\n' "$NC"
}
print_header() {
printf '%b%s%b\n' "$BLUE" "$1" "$NC"
}
print_error() {
printf '%bError:%b %s\n' "$RED" "$NC" "$*" >&2
}
print_warning() {
printf '%bWarning:%b %s\n' "$YELLOW" "$NC" "$*"
}
print_notice() {
printf '%b%s%b\n' "$YELLOW" "$*" "$NC"
}
print_success() {
printf '%b%s%b\n' "$GREEN" "$*" "$NC"
}
print_info() {
printf '%b%s%b\n' "$CYAN" "$*" "$NC"
}
print_colored_list() {
local label="$1" color="$2" items="$3" item
printf '%b%s%b\n' "$color" "$label" "$NC"
while IFS= read -r item; do
[[ -n "$item" ]] || continue
printf ' %b%s%b\n' "$color" "$item" "$NC"
done <<< "$items"
}
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
print_error "another brewup process is already running (PID ${existing_pid})."
exit 75
fi
rm -f "$LOCK_DIR/pid" 2>/dev/null || true
rmdir "$LOCK_DIR" 2>/dev/null || {
print_error "unable to remove stale lock directory: $LOCK_DIR"
exit 75
}
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
print_error "another brewup process acquired the lock."
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
print_info "Retrying ${label} (${attempt}/${attempts}) after ${delay}s..."
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
print_error "failed to write retry log."
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
print_notice "${label} failed with a retryable error."
done
}
get_outdated_formulae() {
env -u HOMEBREW_COLOR brew outdated --formula 2>/dev/null
}
get_outdated_casks() {
env -u HOMEBREW_COLOR brew outdated --cask --greedy 2>/dev/null
}
run_brew_doctor() {
local output status=0 line
if output="$(env -u HOMEBREW_COLOR brew doctor 2>&1)"; then
status=0
else
status=$?
fi
while IFS= read -r line; do
case "$line" in
Warning:*|*deprecated*|*disabled*|*pre-release*|*"find replacements"*|*"do not provide support"*|*"Tier 2 configuration"*)
printf '%b%s%b\n' "$YELLOW" "$line" "$NC"
;;
' '*|' https://'*)
printf '%b%s%b\n' "$CYAN" "$line" "$NC"
;;
*)
printf '%s\n' "$line"
;;
esac
done <<< "$output"
return "$status"
}
run_formula_upgrade() {
local bulk_status=0 initial_formulae="" initial_formulae_known=1
local outdated_formulae remaining_formulae formula
local failed_formulae=()
if ! initial_formulae="$(get_outdated_formulae)"; then
initial_formulae_known=0
print_warning "unable to list Formulae requiring updates before upgrade."
elif [[ -n "$initial_formulae" ]]; then
print_colored_list "Formulae requiring updates:" "$YELLOW" "$initial_formulae"
else
print_success "No Formulae require updates."
fi
brew upgrade --formula || bulk_status=$?
if ((bulk_status == 0)); then
if ((initial_formulae_known)) && [[ -n "$initial_formulae" ]]; then
print_colored_list "Updated Formulae:" "$GREEN" "$initial_formulae"
else
print_success "Formula upgrade completed."
fi
return 0
fi
print_warning "Formula upgrade returned ${bulk_status}; checking remaining Formulae..."
if ! outdated_formulae="$(get_outdated_formulae)"; then
print_warning "unable to verify outdated Formulae."
return "$bulk_status"
fi
if [[ -z "$outdated_formulae" ]]; then
if [[ -n "$initial_formulae" ]]; then
print_colored_list "Updated Formulae:" "$GREEN" "$initial_formulae"
else
print_success "No outdated Formulae remain; the bulk error was recovered."
fi
return 0
fi
while IFS= read -r formula; do
[[ -n "$formula" ]] || continue
print_info "Retrying Formula: ${formula}"
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
print_warning "unable to verify Formulae after individual retries."
FAILED_FORMULAE=("${failed_formulae[@]}")
return 1
fi
if [[ -z "$remaining_formulae" ]]; then
if [[ -n "$initial_formulae" ]]; then
print_colored_list "Updated Formulae after isolated retries:" "$GREEN" "$initial_formulae"
else
print_success "All outdated Formulae were upgraded after isolated retries."
fi
return 0
fi
while IFS= read -r formula; do
[[ -n "$formula" ]] && FAILED_FORMULAE+=("$formula")
done <<< "$remaining_formulae"
print_colored_list "Outdated Formulae remaining after retries:" "$RED" "$remaining_formulae"
return 1
}
run_cask_upgrade() {
local bulk_status=0 initial_casks="" initial_casks_known=1
local 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)
print_warning "HB_CASK_FORCE is enabled; existing Cask files may be overwritten."
fi
if ! initial_casks="$(get_outdated_casks)"; then
initial_casks_known=0
print_warning "unable to list Casks requiring updates before upgrade."
elif [[ -n "$initial_casks" ]]; then
print_colored_list "Casks requiring updates:" "$YELLOW" "$initial_casks"
else
print_success "No Casks require updates."
fi
brew "${bulk_args[@]}" || bulk_status=$?
if ((bulk_status == 0)); then
if ((initial_casks_known)) && [[ -n "$initial_casks" ]]; then
print_colored_list "Updated Casks:" "$GREEN" "$initial_casks"
else
print_success "Cask upgrade completed."
fi
return 0
fi
print_warning "Cask upgrade returned ${bulk_status}; checking remaining Casks..."
if ! outdated_casks="$(get_outdated_casks)"; then
print_warning "unable to verify outdated Casks."
return "$bulk_status"
fi
if [[ -z "$outdated_casks" ]]; then
if [[ -n "$initial_casks" ]]; then
print_colored_list "Updated Casks:" "$GREEN" "$initial_casks"
else
print_success "No outdated Casks remain; the bulk error was recovered."
fi
return 0
fi
while IFS= read -r cask; do
[[ -n "$cask" ]] || continue
print_info "Retrying Cask: ${cask} (without --force)"
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
print_warning "unable to verify Casks after individual retries."
FAILED_CASKS=("${failed_casks[@]}")
return 1
fi
if [[ -z "$remaining_casks" ]]; then
if [[ -n "$initial_casks" ]]; then
print_colored_list "Updated Casks after isolated retries:" "$GREEN" "$initial_casks"
else
print_success "All outdated Casks were upgraded after isolated retries."
fi
return 0
fi
while IFS= read -r cask; do
[[ -n "$cask" ]] && FAILED_CASKS+=("$cask")
done <<< "$remaining_casks"
print_colored_list "Outdated Casks remaining after retries:" "$RED" "$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]}"
}
is_cache_download_artifact() {
[[ "$1" == downloads/* ]]
}
cache_artifact_display_name() {
local name="${1#downloads/}"
# Homebrew 下载文件通常以 SHA-256-- 开头;终端只显示有意义的原文件名。
[[ "$name" == *--* ]] && name="${name#*--}"
printf '%s' "$name"
}
prune_cache_before_today() {
local cache_dir cutoff_stamp cutoff_date file file_size relative_path display_name hidden_count
local removed_count=0 removed_bytes=0 kept_count=0 kept_bytes=0 cleanup_failed=0
local removed_artifact_count=0 removed_artifact_bytes=0
local kept_artifact_count=0 kept_artifact_bytes=0
cache_dir="$(brew --cache)"
if [[ ! -d "$cache_dir" || "$cache_dir" == "/" || "$cache_dir" == "$HOME" ]]; then
print_error "Homebrew returned an unsafe cache path: $cache_dir"
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
print_error "failed to enumerate Homebrew cache files to remove."
return 1
fi
printf '%bRemoved download artifacts:%b\n' "$YELLOW" "$NC"
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))
if is_cache_download_artifact "$relative_path"; then
removed_artifact_count=$((removed_artifact_count + 1))
removed_artifact_bytes=$((removed_artifact_bytes + file_size))
display_name="$(cache_artifact_display_name "$relative_path")"
printf ' %b%10s%b %q\n' "$CYAN" "$(format_bytes "$file_size")" "$NC" "$display_name"
fi
else
print_warning "failed to remove cache file: $file" >&2
cleanup_failed=1
fi
done < "$ACTIVE_CLEANUP_MANIFEST"
((removed_artifact_count > 0)) || printf ' (none)\n'
hidden_count=$((removed_count - removed_artifact_count))
printf '%bRemoved:%b %b%d files / %s%b before %b%s%b.\n' \
"$YELLOW" "$NC" "$CYAN" "$removed_count" "$(format_bytes "$removed_bytes")" "$NC" \
"$BLUE" "$cutoff_date" "$NC"
printf ' Downloads shown: %b%d / %s%b; internal files hidden: %b%d%b.\n' \
"$CYAN" "$removed_artifact_count" "$(format_bytes "$removed_artifact_bytes")" "$NC" \
"$CYAN" "$hidden_count" "$NC"
if ! find "$cache_dir" -mindepth 1 \( -type f -o -type l \) \
-newer "$ACTIVE_CLEANUP_MARKER" -print0 > "$ACTIVE_CLEANUP_MANIFEST"; then
print_error "failed to enumerate today's retained Homebrew cache files."
return 1
fi
printf '%bRetained download artifacts from today:%b\n' "$GREEN" "$NC"
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))
if is_cache_download_artifact "$relative_path"; then
kept_artifact_count=$((kept_artifact_count + 1))
kept_artifact_bytes=$((kept_artifact_bytes + file_size))
display_name="$(cache_artifact_display_name "$relative_path")"
printf ' %b%10s%b %q\n' "$CYAN" "$(format_bytes "$file_size")" "$NC" "$display_name"
fi
done < "$ACTIVE_CLEANUP_MANIFEST"
((kept_artifact_count > 0)) || printf ' (none)\n'
hidden_count=$((kept_count - kept_artifact_count))
printf '%bRetained:%b %b%d files / %s%b dated %b%s%b or later.\n' \
"$GREEN" "$NC" "$CYAN" "$kept_count" "$(format_bytes "$kept_bytes")" "$NC" \
"$BLUE" "$cutoff_date" "$NC"
printf ' Downloads shown: %b%d / %s%b; internal files hidden: %b%d%b.\n' \
"$CYAN" "$kept_artifact_count" "$(format_bytes "$kept_artifact_bytes")" "$NC" \
"$CYAN" "$hidden_count" "$NC"
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
print_notice "Cleanup skipped because HB_SKIP_CLEANUP is enabled."
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
print_error "HB_CLEANUP_DAYS must be an integer between 0 and 3650."
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
print_success "All operations completed successfully."
else
printf '%bHomebrew upgrade completed with unresolved failures.%b\n' "$RED" "$NC"
if ((${#FAILED_FORMULAE[@]} > 0)); then
printf '%bFailed Formulae:%b %s\n' "$RED" "$NC" "${FAILED_FORMULAE[*]}"
fi
if ((${#FAILED_CASKS[@]} > 0)); then
printf '%bFailed Casks:%b %s\n' "$RED" "$NC" "${FAILED_CASKS[*]}"
fi
fi
separator
}
parse_args "$@"
resolve_terminal_width
command -v brew >/dev/null 2>&1 || { print_error "Homebrew is not installed or not in PATH."; exit 127; }
command -v tee >/dev/null 2>&1 || { print_error "tee is required."; 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
print_notice "brew doctor skipped because HB_SKIP_DOCTOR is enabled."
elif ! run_brew_doctor; then
print_warning "'brew doctor' detected issues. Manual review is recommended."
else
print_success "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
printf '\n'
print_info ">>> [1/2] Upgrading CLI Formulae..."
if ! run_formula_upgrade; then
overall_status=1
fi
printf '\n'
print_info ">>> [2/2] Upgrading GUI Casks..."
if ! run_cask_upgrade; then
overall_status=1
fi
separator
printf '\n'
separator
print_header "Step 4: Cleaning old cache (showing download artifacts only)"
if ! run_cleanup; then
overall_status=1
fi
separator
printf '\n'
print_summary "$overall_status"
exit "$overall_status"