Files
dotfiles/local/bin/x-asdf-cleanup
2025-02-25 11:09:00 +02:00

1297 lines
33 KiB
Bash
Executable File

#!/usr/bin/env bash
# This script checks if all versions of tools installed via asdf are in use
# in any .tool-versions file in the home directory or its subdirectories,
# excluding some directories that shouldn't be scanned.
#
# It will print out the tools and versions that are not in use and remove them.
#
# This script is useful for cleaning up old versions of tools that are no longer
# in use and are taking up extra space on your system.
#
# Usage: x-asdf-cleanup
# Author: Ismo Vuorinen <https://github.com/ivuorinen>
# License: MIT
#
# vim: set ft=sh ts=2 sw=2 et: ft=sh
set -u
VERSION="1.0.0"
BASENAME=$(basename "$0")
# Additional configuration
USE_CACHE=true
CACHE_MAX_AGE=3600 # 1 hour in seconds
# Logging and backup configuration
readonly LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/asdf-cleanup"
readonly LOG_FILE="$LOG_DIR/cleanup.log"
readonly BACKUP_DIR="$LOG_DIR/backups"
readonly LAST_OP_FILE="$LOG_DIR/last_operation"
# Define colors for output formatting
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly NC='\033[0m' # No Color
readonly BOLD='\033[1m'
# Create temporary file and ensure it's removed on exit
readonly TMPFILE=$(mktemp)
# Signal handling and cleanup
trap 'cleanup' INT TERM
trap 'error_handler $?' ERR
trap 'error_occurred $? $LINENO $BASH_LINENO "$BASH_COMMAND" $(printf "::%s" ${FUNCNAME[@]:-})' ERR
trap 'rm -f "$TMPFILE"' EXIT
# Performance monitoring variables
readonly START_TIME=$SECONDS
readonly START_MEM=$(ps -o rss= -p $$)
# Default configuration
BASE_DIR="$HOME"
DRYRUN=false
VERBOSE=false
DEBUG=false
MAX_PARALLEL_JOBS=4
# Global arrays for version management
declare -A defined_versions=()
declare -A installed_versions=()
declare -A keep_version=()
declare -A all_versions=()
declare -a uninstall_list=()
# Supported tools and their version files
declare -A VERSION_FILES=(
["nodejs"]=".nvmrc"
["python"]=".python-version"
["asdf"]=".tool-versions"
)
# Define the exclude patterns
EXCLUDE_PATTERNS=(
".bundle"
".cache"
".git"
".local/state"
".npm"
".Trash"
".vscode"
"bower_components"
"gems"
"Library"
"node_modules"
"Photos"
"vendor"
)
# Output formatting functions
print_success()
{
echo -e "${GREEN}$1${NC}"
}
print_error()
{
echo -e "${RED}$1${NC}"
}
print_warning()
{
echo -e "${YELLOW}$1${NC}"
}
# Cleanup and error handling
cleanup()
{
print_warning "Received termination signal"
rm -f "$TMPFILE"
exit 1
}
# Sort array keys by tool name
sort_by_tool()
{
local -n array=$1
local -a sorted_keys
readarray -t sorted_keys < <(printf '%s\n' "${!array[@]}" | sort)
echo "${sorted_keys[@]}"
}
error_handler()
{
local exit_code=$1
print_error "An error occurred (exit code: $exit_code)"
cleanup
}
error_occurred()
{
local exit_code=$1
local line_no=$2
local bash_lineno=$3
local last_command=$4
local func_trace=$5
echo "Error occurred in:"
echo " Exit code: $exit_code"
echo " Line number: $line_no ($bash_lineno)"
echo " Command: $last_command"
echo " Function trace: $func_trace"
}
# Logging function with different levels
log()
{
local level=$1
shift
local message=$*
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
if [[ $VERBOSE == true ]] || [[ $level == "ERROR" ]]; then
echo "[$timestamp][$level] $message"
fi
}
# File logging function
log_to_file()
{
local level=$1
shift
local message=$*
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp][$level] $message" >> "$LOG_FILE"
}
# Error handling function
handle_error()
{
local exit_code=$1
local error_message=$2
print_error "Error ($exit_code): $error_message" >&2
log_to_file "ERROR" "$error_message"
exit "$exit_code"
}
# Debug function
debug()
{
if [[ $DEBUG == true ]]; then
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp][DEBUG] $*" >&2
fi
}
debug_array()
{
local -n array="$1"
local name="$2"
if [[ $DEBUG == true ]]; then
echo "Debug: Contents of $name:"
for key in "${!array[@]}"; do
echo " $key => ${array[$key]}"
done
fi
}
# Debug information function
debug_info()
{
echo "----------------------------------------"
echo "Script variables:"
echo "----------------------------------------"
echo " BASE_DIR = $BASE_DIR"
echo " BASE_DIR IS DIR = $(is_dir "$BASE_DIR")"
echo " DEBUG = $DEBUG"
echo " DRY_RUN = $DRYRUN"
echo " EXCLUDE_PATTERNS = ${EXCLUDE_PATTERNS[*]}"
echo " VERBOSE = $VERBOSE"
echo " VERSION = $VERSION"
echo " Remaining arguments:"
for var in "$@"; do
echo " $var"
done
echo "----------------------------------------"
echo "Tool versions:"
echo "----------------------------------------"
echo "asdf version: $(asdf --version 2> /dev/null || echo "not found")"
echo "fd version: $(fd --version 2> /dev/null || echo "not found")"
echo "jq version: $(jq --version 2> /dev/null || echo "not found")"
echo "----------------------------------------"
# Enable debug mode for the rest of the script
DEBUG=true
return 0
}
# Display usage information
usage()
{
cat << EOF
Usage: $BASENAME [OPTIONS]
A tool to clean up unused asdf tool versions from your system.
Options:
--base-dir=DIR Specify the base directory to search for version files (default: \$HOME)
--dry-run Perform a dry run without uninstalling any versions
--exclude=DIR Exclude a directory from the search path (can be used multiple times)
--debug Show debug information and enable debug mode
--verbose Enable verbose output
--no-cache Disable version cache
--restore Restore the last uninstalled version from backup
--max-parallel=N Set maximum number of parallel processes (default: 4)
-h, --help Show this help message and exit
-v, --version Show version information and exit
Environment variables:
DEBUG Enable debug mode
VERBOSE Enable verbose output
XDG_CONFIG_HOME Configuration directory (default: ~/.config)
XDG_CACHE_HOME Cache directory (default: ~/.cache)
Files:
~/.config/asdf-cleanup/config Configuration file
~/.cache/asdf-cleanup/* Cache files
Examples:
$BASENAME --dry-run
$BASENAME --exclude=node_modules --exclude=vendor
$BASENAME --verbose --debug
$BASENAME --base-dir=~/projects --max-parallel=8
EOF
exit 0
}
# Function to check dependencies
check_dependencies()
{
local -a required_tools=(asdf fd jq)
local missing_tools=()
for tool in "${required_tools[@]}"; do
if ! command -v "$tool" > /dev/null 2>&1; then
missing_tools+=("$tool")
fi
done
if ((${#missing_tools[@]} > 0)); then
print_error "Missing required tools: ${missing_tools[*]}"
if [[ " ${missing_tools[*]} " =~ " fd " ]]; then
echo "Install fd with asdf:"
echo "asdf plugin add fd && asdf install fd latest"
fi
if [[ " ${missing_tools[*]} " =~ " jq " ]]; then
echo "Install jq with asdf:"
echo "asdf plugin add jq && asdf install jq latest"
fi
exit 1
fi
}
# Initialize required directories
initialize_directories()
{
local -a dirs=("$LOG_DIR" "$BACKUP_DIR" "$cache_dir")
for dir in "${dirs[@]}"; do
if [[ ! -d $dir ]]; then
mkdir -p "$dir" || handle_error 1 "Failed to create directory: $dir"
fi
done
}
# Load configuration file if it exists
load_config()
{
local config_file="${XDG_CONFIG_HOME:-$HOME/.config}/asdf-cleanup/config"
if [[ -f $config_file ]]; then
debug "Loading configuration from $config_file"
# shellcheck source=/dev/null
source "$config_file"
fi
}
# Show performance metrics
show_performance_metrics()
{
local end_time=$SECONDS
local end_mem
end_mem=$(ps -o rss= -p $$)
local duration=$((end_time - START_TIME))
local mem_usage=$((end_mem - START_MEM))
log "INFO" "Execution time: ${duration}s"
log "INFO" "Memory usage: ${mem_usage}KB"
log_to_file "INFO" "Performance: Time=${duration}s, Memory=${mem_usage}KB"
}
# Progress indicator
show_progress()
{
local -r pid="$1"
local -r delay='0.75'
local spinstr='|/-\'
local temp
while ps a | awk '{print $1}' | grep -q "$pid"; do
temp="${spinstr#?}"
printf " [%c] " "$spinstr"
spinstr=$temp${spinstr%"$temp"}
sleep $delay
printf "\b\b\b\b\b\b"
done
printf " \b\b\b\b"
}
# Semantic version comparison
version_compare()
{
local IFS=.
local i ver1=($1) ver2=($2)
debug "Comparing versions: $1 vs $2"
for ((i = 0; i < ${#ver1[@]} || i < ${#ver2[@]}; i++)); do
local v1=${ver1[i]:-0} v2=${ver2[i]:-0}
debug "Comparing components: $v1 vs $v2"
((v1 > v2)) && {
debug "First version is greater"
return 1
}
((v1 < v2)) && {
debug "First version is less"
return 2
}
done
debug "Versions are equal"
return 0
}
# Cache handling functions
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/asdf-cleanup"
cache_file="$cache_dir/version-cache"
get_file_mtime()
{
local file="$1"
local mtime
# Check which platform we're on and use appropriate stat command
case "$(uname)" in
"Darwin") # macOS
mtime=$(stat -f "%m" "$file")
;;
"Linux") # Linux
mtime=$(stat -c "%Y" "$file")
;;
*)
# Default fallback, might not work on all systems
mtime=$(date +%s -r "$file")
;;
esac
echo "$mtime"
}
read_cache_versions()
{
local -n output_array="$1"
local content
debug "Reading cache from: $cache_file"
debug "Cache file contents:"
debug "$(cat "$cache_file")"
# Check that the file exists and is not empty
if [[ ! -f $cache_file || ! -s $cache_file ]]; then
debug "Cache file doesn't exist or is empty"
return 1
fi
# Check that the JSON is valid and contains the versions object
if ! jq -e '.versions' "$cache_file" > /dev/null 2>&1; then
debug "Cache file doesn't contain valid versions data"
return 1
fi
while IFS= read -r line; do
[[ -n $line ]] || continue
local key value
key=$(echo "$line" | jq -r '.key')
value=$(echo "$line" | jq -r '.value')
output_array["$key"]="$value"
done < <(jq -c '.versions | to_entries[]' "$cache_file")
}
write_cache_versions()
{
local -n input_array="$1"
local tmp_file
tmp_file=$(mktemp)
# Create a JSON object with the versions
echo "{" > "$tmp_file"
echo ' "versions": {' >> "$tmp_file"
local first=true
for key in "${!input_array[@]}"; do
if [[ $first == true ]]; then
first=false
else
echo "," >> "$tmp_file"
fi
printf ' "%s": "%s"' \
"$(echo "$key" | sed 's/"/\\"/g')" \
"${input_array[$key]}" >> "$tmp_file"
done
echo >> "$tmp_file"
echo " }" >> "$tmp_file"
echo "}" >> "$tmp_file"
# Validate JSON before moving it
if jq '.' "$tmp_file" > /dev/null 2>&1; then
mv "$tmp_file" "$cache_file"
else
print_error "Failed to create valid JSON cache"
rm -f "$tmp_file"
return 1
fi
}
initialize_cache()
{
mkdir -p "$cache_dir"
if [[ ! -f $cache_file ]]; then
echo '{"versions":{}}' > "$cache_file"
fi
}
cache_is_valid()
{
if [[ ! -f $cache_file ]] || [[ ! -s $cache_file ]]; then
debug "Cache file doesn't exist or is empty"
return 1
fi
local mtime
mtime=$(get_file_mtime "$cache_file")
local current_time
current_time=$(date +%s)
debug "Cache mtime: $mtime"
debug "Current time: $current_time"
debug "Cache age: $((current_time - mtime)) seconds"
# Check that the cache contains valid data
if ! jq -e '.versions' "$cache_file" > /dev/null 2>&1; then
debug "Cache file doesn't contain valid versions data"
return 1
fi
if ((current_time - mtime < CACHE_MAX_AGE)); then
debug "Cache is valid (age < $CACHE_MAX_AGE seconds)"
return 0
else
debug "Cache is too old"
return 1
fi
}
update_cache()
{
local type=$1
local data=$2
local tmp_file
tmp_file=$(mktemp)
if ! jq --arg type "$type" --arg data "$data" \
'.[$type] = $data' "$cache_file" > "$tmp_file"; then
print_warning "Failed to update cache"
log_to_file "WARNING" "Failed to update cache for type: $type"
rm -f "$tmp_file"
return 1
fi
mv "$tmp_file" "$cache_file"
}
read_cache()
{
local type=$1
jq -r --arg type "$type" '.[$type] // empty' "$cache_file"
}
read_cache_versions()
{
# Check that the output array is properly initialized
if [[ -z ${!1+x} ]]; then
debug "Output array not properly initialized"
return 1
fi
local -n output_array="$1"
local content
debug "Reading cache file: $cache_file"
# Check that the file exists and is not empty
if [[ ! -f $cache_file ]] || [[ ! -s $cache_file ]]; then
debug "Cache file doesn't exist or is empty"
return 1
fi
# Check the JSON structure
if ! content=$(jq -e '.versions' "$cache_file" 2> /dev/null); then
debug "Invalid JSON structure in cache file"
return 1
fi
# Empty the target array for safety
output_array=()
# Create an associative array from the JSON data
while IFS= read -r line; do
[[ -n $line ]] || continue
local key value
key=$(echo "$line" | jq -r '.key')
value=$(echo "$line" | jq -r '.value')
if [[ -n $key && $key != "null" ]]; then
debug "Adding cached version: $key = $value"
output_array["$key"]="$value"
fi
done < <(jq -c '.versions | to_entries[]' "$cache_file")
debug "Read ${#output_array[@]} versions from cache"
return 0
}
write_cache_versions()
{
local -n input_array="$1"
local tmp_file
tmp_file=$(mktemp)
# Convert the associative array to JSON format
printf '{"versions":{\n' > "$tmp_file"
local first=true
for key in "${!input_array[@]}"; do
if [[ $first == true ]]; then
first=false
else
printf ',\n' >> "$tmp_file"
fi
printf ' "%s": %s' "$key" "${input_array[$key]}" >> "$tmp_file"
done
printf '\n}}\n' >> "$tmp_file"
mv "$tmp_file" "$cache_file"
}
copy_versions()
{
local -n dest="$1"
local -n source="$2"
for key in "${!source[@]}"; do
dest["$key"]="${source[$key]}"
done
}
# Function to check if directory exists
is_dir()
{
local dir="$1"
if [[ -d $dir ]]; then
echo "yes"
else
echo "no"
fi
}
# Trim whitespace from string
trim_whitespace()
{
local var="$1"
# Remove leading whitespace
var="${var#"${var%%[![:space:]]*}"}"
# Remove trailing whitespace
var="${var%"${var##*[![:space:]]}"}"
echo "$var"
}
# Process .tool-versions file
process_tool_versions_file()
{
local file="$1"
if [[ ! -f $file ]]; then
debug "File not found: $file"
return 1
fi
# Process file content and output "tool version" pairs
# Ignore comments and empty lines, require at least tool and version
awk '!/#/ && NF >= 2 {print $1, $2}' "$file"
}
# Version file processing functions
process_version_files()
{
local file_type="$1"
local array_name="$2"
local -A local_versions=()
debug "Processing version files for $file_type"
while IFS= read -r relative_path; do
[[ -z $relative_path ]] && continue
# Create an absolute path
local file="${BASE_DIR}/${relative_path}"
debug "Processing file: $file (relative: $relative_path)"
if ! validate_version_file "$file" "$file_type"; then
debug "Invalid version file format: $file"
continue
fi
case "$file_type" in
"nodejs")
local version
version=$(< "$file")
debug "Raw nodejs version content: '$version'"
version=$(trim_whitespace "$version")
# Handle lts/* separately
if [[ $version == "lts/*" ]] || [[ $version =~ ^lts/[*]$ ]]; then
version="lts/*"
else
version=${version#v} # Remove leading 'v' if present
fi
debug "Processed nodejs version: '$version'"
local_versions["nodejs ${version}"]=1
;;
"python")
local content
content=$(< "$file")
debug "Raw python version content: '$content'"
content=$(trim_whitespace "$content")
for version in $content; do
version=$(trim_whitespace "$version")
[[ -z $version || $version == \#* ]] && continue
debug "Found python version: '$version'"
local_versions["python ${version}"]=1
done
;;
"asdf")
while IFS= read -r line; do
line=$(trim_whitespace "$line")
[[ -z $line || $line == \#* ]] && continue
local tool version
read -r tool version <<< "$line"
tool=$(trim_whitespace "$tool")
version=$(trim_whitespace "$version")
debug "Found tool and version: '$tool' '$version'"
local_versions["$tool ${version}"]=1
done < "$file"
;;
esac
done < <(find_version_files "$file_type")
debug "Finished processing $file_type, found ${#local_versions[@]} versions"
# Return the local versions as an associative array
declare -p local_versions
}
# Validate version file format
validate_version_file()
{
local file="$1"
local file_type="$2"
debug "Validating file: $file"
[[ -f $file ]] || {
debug "File does not exist: $file"
return 1
}
[[ -r $file ]] || {
debug "File is not readable: $file"
return 2
}
local content
content=$(< "$file")
content=$(trim_whitespace "$content")
[[ -z $content ]] && {
debug "File is empty: $file"
return 1
}
debug "Validating $file_type file: '$file' with content: '$content'"
case "$file_type" in
"nodejs")
# Support both direct versions and lts/* notation
if [[ $content =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| [[ $content =~ ^lts/[*]$ ]] \
|| [[ $content == "lts/*" ]]; then
debug "Valid nodejs version: $content"
return 0
else
debug "Invalid nodejs version format: $content"
fi
;;
"python")
# Support both single and multiple versions
local valid=true
for ver in $content; do
debug "Checking Python version: $ver"
if ! [[ $ver =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
debug "Invalid Python version format: $ver"
valid=false
break
fi
done
if $valid; then
debug "Valid Python version(s): $content"
return 0
fi
;;
"asdf")
# Support .tool-versions format
local valid=true
while IFS= read -r line || [[ -n $line ]]; do
line=$(trim_whitespace "$line")
debug "Checking .tool-versions line: '$line'"
[[ -z $line || $line == \#* ]] && continue
if ! [[ $line =~ ^[a-zA-Z0-9_-]+[[:space:]]+[a-zA-Z0-9._*/-]+$ ]]; then
debug "Invalid .tool-versions format: '$line'"
valid=false
break
else
debug "Valid .tool-versions line: '$line'"
fi
done <<< "$content"
if $valid; then
debug "Valid .tool-versions file"
return 0
fi
;;
esac
return 1
}
# Find version files using fd
find_version_files()
{
local file_type="$1"
local pattern="${VERSION_FILES[$file_type]}"
debug "Searching for $file_type version files with pattern: $pattern"
debug "Base directory: $BASE_DIR"
debug "Exclude patterns: ${EXCLUDE_PATTERNS[*]}"
if [[ -z $pattern ]]; then
handle_error 1 "Unknown version file type: $file_type"
fi
local fd_args=(
--base-directory "$BASE_DIR"
--type f
--hidden
--glob "$pattern"
)
# Add all exclude patterns
for exclude in "${EXCLUDE_PATTERNS[@]}"; do
fd_args+=(--exclude "$exclude")
done
debug "fd command arguments: ${fd_args[*]}"
local files
files=$(fd "${fd_args[@]}" 2> /dev/null)
debug "Found files for $file_type:"
debug "$files"
echo "$files"
}
# Process all version files in parallel
process_all_version_files()
{
local -n output_array="$1"
debug "Starting to process version files..."
output_array=()
for type in "${!VERSION_FILES[@]}"; do
debug "Processing type: $type"
local versions_data
versions_data=$(process_version_files "$type" "temp_versions")
if [[ $versions_data == "declare -A"* ]]; then
eval "local -A type_versions=${versions_data#*=}"
for key in "${!type_versions[@]}"; do
output_array["$key"]=1
debug "Added version: $key"
done
fi
done
debug "Total versions found: ${#output_array[@]}"
}
# Backup functions
create_backup()
{
local tool=$1
local version=$2
local backup_path="$BACKUP_DIR/${tool}_${version}_$(date +%Y%m%d_%H%M%S)"
local tool_path
tool_path=$(asdf where "$tool" "$version")
# Check backup directory permissions
if [[ ! -w $BACKUP_DIR ]]; then
debug "Backup directory not writable: $BACKUP_DIR"
return 1
fi
if [[ -d $tool_path ]]; then
debug "Creating backup of $tool $version"
log_to_file "INFO" "Creating backup of $tool $version"
if tar czf "$backup_path.tar.gz" -C "$(dirname "$tool_path")" "$(basename "$tool_path")"; then
# Verify backup integrity
if ! tar tzf "$backup_path.tar.gz" > /dev/null 2>&1; then
debug "Backup verification failed"
rm -f "$backup_path.tar.gz"
return 1
fi
echo "$tool $version" > "$LAST_OP_FILE"
return 0
fi
fi
return 1
}
restore_last_backup()
{
if [[ ! -f $LAST_OP_FILE ]]; then
print_error "No backup information found"
return 1
fi
read -r tool version < "$LAST_OP_FILE"
local backup_file
backup_file=$(find "$BACKUP_DIR" -name "${tool}_${version}_*.tar.gz" | sort -r | head -n1)
if [[ -f $backup_file ]]; then
local install_path
install_path=$(asdf where "$tool" "$version" 2> /dev/null || echo "$HOME/.asdf/installs/$tool/$version")
print_warning "Restoring $tool $version from backup"
log_to_file "INFO" "Restoring $tool $version from backup"
mkdir -p "$(dirname "$install_path")"
if tar xzf "$backup_file" -C "$(dirname "$install_path")"; then
print_success "Restored $tool $version successfully"
log_to_file "INFO" "Successfully restored $tool $version"
return 0
fi
fi
print_error "Backup not found or restore failed"
log_to_file "ERROR" "Failed to restore $tool $version"
return 1
}
# Read installed versions from asdf
read_installed_versions()
{
local current_tool=""
local line
debug "Reading installed versions from asdf"
# Capture asdf output and check for errors
local asdf_output
if ! asdf_output=$(asdf list 2>&1); then
print_error "Failed to read installed versions: $asdf_output"
log_to_file "ERROR" "Failed to read installed versions: $asdf_output"
return 1
fi
while IFS= read -r line || [[ -n $line ]]; do
if [[ $line =~ ^[^[:space:]] ]]; then
current_tool=$(trim_whitespace "$line")
continue
fi
line=$(trim_whitespace "$line")
if [[ $line == "No versions installed" ]]; then
print_warning "No versions installed for $current_tool"
log_to_file "WARNING" "No versions installed for $current_tool"
continue
fi
# Extract version, removing the asterisk if present
local version
if [[ $line =~ ^\* ]]; then
version=$(trim_whitespace "${line#\*}")
keep_version["$current_tool $version"]=1
debug "Keeping version: $current_tool $version"
else
version=$(trim_whitespace "$line")
fi
installed_versions["$current_tool $version"]=1
debug "Added installed version: $current_tool $version"
done <<< "$asdf_output"
}
# Group and sort versions by tool
group_versions()
{
local array_name="$1"
local -A tools=()
local tool version
# Safely create a temporary array for the keys
declare -a keys
while IFS= read -r key; do
[[ -n $key ]] && keys+=("$key")
done < <(eval "printf '%s\n' \"\${!$array_name[@]}\"")
# Group versions by tool
for key in "${keys[@]}"; do
tool="${key%% *}"
version="${key#* }"
if [[ -n ${tools["$tool"]-} ]]; then
tools["$tool"]="${tools["$tool"]}, $version"
else
tools["$tool"]="$version"
fi
done
# Sort and output with bold tool names
while IFS= read -r tool; do
[[ -n $tool ]] && echo -e " ${BOLD}${tool}${NC} ${tools["$tool"]}"
done < <(printf '%s\n' "${!tools[@]}" | sort)
}
# Update display functions to use the new grouping
display_defined_versions()
{
echo ""
print_success "All Defined Versions in .nvmrc, .python-version and .tool-versions files:"
group_versions defined_versions
echo
log_to_file "INFO" "Found ${#defined_versions[@]} defined version(s)"
}
display_installed_versions()
{
print_success "All Installed Versions:"
group_versions installed_versions
echo
log_to_file "INFO" "Found ${#installed_versions[@]} installed version(s)"
}
display_versions_to_keep()
{
print_success "Versions to Keep (tools set as global):"
group_versions keep_version
echo
log_to_file "INFO" "Found ${#keep_version[@]} version(s) to keep"
}
# Parse arguments function
parse_arguments()
{
local arg
for arg in "$@"; do
case $arg in
-h | --help)
usage
;;
-v | --version)
version
;;
--base-dir=*)
BASE_DIR="${arg#*=}"
;;
--dry-run)
DRYRUN=true
;;
--exclude=*)
EXCLUDE_PATTERNS+=("${arg#*=}")
;;
--verbose)
VERBOSE=true
;;
--debug)
DEBUG=true
debug_info "$@"
;;
--no-cache)
USE_CACHE=false
;;
--restore)
restore_last_backup
exit
;;
--max-parallel=*)
MAX_PARALLEL_JOBS="${arg#*=}"
if ! [[ $MAX_PARALLEL_JOBS =~ ^[0-9]+$ ]]; then
handle_error 1 "Invalid value for max-parallel: $MAX_PARALLEL_JOBS"
fi
;;
*)
print_error "Unknown option: $arg"
usage
;;
esac
done
}
# Version information with added details
version()
{
cat << EOF
$BASENAME version $VERSION
Author: Ismo Vuorinen <https://github.com/ivuorinen>
License: MIT
System information:
OS: $(uname -s)
Shell: $SHELL
BASH Version: $BASH_VERSION
Dependencies:
asdf: $(asdf --version 2> /dev/null || echo "not found")
fd: $(fd --version 2> /dev/null || echo "not found")
jq: $(jq --version 2> /dev/null || echo "not found")
EOF
exit 0
}
# Update determine_versions_to_uninstall to use the same formatting
determine_versions_to_uninstall()
{
print_warning "Versions to Uninstall:"
local count=0
declare -A to_uninstall=() # Declare associative array
uninstall_list=() # Empty the uninstall list
debug "Determining versions to uninstall..."
debug "Installed versions: ${!installed_versions[*]}"
debug "Defined versions: ${!defined_versions[*]}"
debug "Keep versions: ${!keep_version[*]}"
for key in "${!installed_versions[@]}"; do
local name="${key% *}"
local version="${key#* }"
local keep=false
debug "Checking $name version $version"
# Check if the version is in the keep_version list
if [[ -v keep_version[$key] ]]; then
debug "$name $version is in keep_version list"
continue
fi
debug "Version not in keep_version list, checking defined versions"
# Check if the version is defined in the defined_versions list
for defined_key in "${!defined_versions[@]}"; do
local defined_name="${defined_key% *}"
local defined_version="${defined_key#* }"
if [[ $name == "$defined_name" ]]; then
debug "Found matching defined version for $name: $defined_version (installed: $version)"
version_compare "$version" "$defined_version"
local compare_result=$?
debug "Version comparison result: $compare_result"
if [[ $compare_result -eq 0 ]] || [[ $compare_result -eq 1 ]]; then
debug "Keeping $name $version (equal or newer than defined version)"
keep=true
break
fi
fi
done
if [[ $keep == false ]]; then
debug "Will uninstall $name $version"
((count++))
# Check if the tool already has versions and initialize if necessary
if [[ -v to_uninstall[$name] ]]; then
to_uninstall[$name]="${to_uninstall[$name]}, $version"
debug "Added version to existing tool: $name ${to_uninstall[$name]}"
else
to_uninstall[$name]="$version"
debug "Added new tool and version: $name $version"
fi
uninstall_list+=("$key")
debug "Added to uninstall list: $key (total: ${#uninstall_list[@]})"
fi
done
debug "Processing complete. Found $count versions to uninstall"
debug "Uninstall list contains: ${uninstall_list[*]}"
debug "To uninstall map contains: ${!to_uninstall[*]}"
# Display grouped uninstall versions
if ((count > 0)); then
local -a sorted_tools
readarray -t sorted_tools < <(printf '%s\n' "${!to_uninstall[@]}" | sort)
for tool in "${sorted_tools[@]}"; do
echo " $tool ${to_uninstall[$tool]}"
debug "Displaying: $tool ${to_uninstall[$tool]}"
done
else
debug "No versions to uninstall"
fi
echo "Found $count version(s) to uninstall"
log_to_file "INFO" "Found $count version(s) to uninstall"
echo
debug "Exiting determine_versions_to_uninstall"
return 0
}
# Uninstall versions with progress indication
uninstall_versions()
{
if ((${#uninstall_list[@]} == 0)); then
print_success "No versions to uninstall."
return
fi
if [[ $DRYRUN == true ]]; then
print_warning "Dry run mode - would uninstall these versions:"
for key in "${uninstall_list[@]}"; do
echo " Would uninstall: $key"
done
log_to_file "INFO" "Dry run completed, would uninstall ${#uninstall_list[@]} version(s)"
return
fi
local confirm
read -rp "Do you want to proceed with uninstallation? (y/N): " confirm
if [[ $confirm =~ ^[Yy]$ ]]; then
local total=${#uninstall_list[@]}
local current=0
for key in "${uninstall_list[@]}"; do
((current++))
local name="${key% *}"
local version="${key#* }"
print_success "($current/$total) Uninstalling $name: $version"
# Create backup before uninstalling
if ! create_backup "$name" "$version"; then
print_warning "Failed to create backup for $name $version"
continue
fi
if ! asdf uninstall "$name" "$version"; then
print_error "Failed to uninstall $name $version"
log_to_file "ERROR" "Failed to uninstall $name $version"
else
log_to_file "INFO" "Successfully uninstalled $name $version"
fi
done
print_success "Uninstallation completed"
log_to_file "INFO" "Uninstallation process completed"
else
print_warning "Uninstallation aborted by user."
log_to_file "INFO" "Uninstallation aborted by user"
fi
}
# Main execution
main()
{
check_dependencies
initialize_directories
load_config
parse_arguments "$@"
initialize_cache
if [[ $(is_dir "$BASE_DIR") == "no" ]]; then
handle_error 1 "Base directory $BASE_DIR does not exist or is not a directory"
fi
print_success "Starting asdf cleanup process..."
log_to_file "INFO" "Starting cleanup process"
# Make sure the array is initialized
declare -A all_versions=()
debug "Checking cache status..."
if [[ $USE_CACHE == true ]] && cache_is_valid; then
debug "Cache is valid, reading from cache..."
if read_cache_versions all_versions && ((${#all_versions[@]} > 0)); then
debug "Successfully read ${#all_versions[@]} versions from cache"
debug_array all_versions "Cached versions"
else
debug "Cache read failed or was empty, processing version files..."
all_versions=() # Empty the array for safety
process_all_version_files all_versions
debug_array all_versions "Processed versions"
if [[ $USE_CACHE == true ]] && ((${#all_versions[@]} > 0)); then
debug "Writing ${#all_versions[@]} versions to cache..."
write_cache_versions all_versions
fi
fi
else
debug "Cache is not valid or disabled, processing version files..."
all_versions=() # Empty the array for safety
process_all_version_files all_versions
debug_array all_versions "Processed versions"
if [[ $USE_CACHE == true ]] && ((${#all_versions[@]} > 0)); then
debug "Writing ${#all_versions[@]} versions to cache..."
write_cache_versions all_versions
fi
fi
# If no versions were found, try again
if ((${#all_versions[@]} == 0)); then
debug "No versions found, forcing version file processing..."
all_versions=() # Empty the array for safety
process_all_version_files all_versions
debug_array all_versions "Processed versions (forced)"
fi
# Declare an associative array for defined versions
declare -A defined_versions=()
debug "Copying ${#all_versions[@]} versions to defined_versions..."
copy_versions defined_versions all_versions
debug_array defined_versions "Defined versions"
# Continue normally...
display_defined_versions
read_installed_versions
display_installed_versions
display_versions_to_keep
determine_versions_to_uninstall
uninstall_versions
show_performance_metrics
print_success "Cleanup process completed"
log_to_file "INFO" "Cleanup process completed successfully"
}
# Execute main function only if script is run directly
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
main "$@"
fi