Files

1193 lines
37 KiB
Bash

# This script is meant to be sourced.
# It's not for directly running.
# shellcheck shell=bash
#####################################################################################
# Notes by @clsty:
#
# I'm not the one who developed this script (see issue#2284 which discussed about the history).
# However it contains many unnecessary logics. This is typically what AI will do.
# I don't really care if it's AI-generated or not, it's just an extra option in addition to ./setup install, so as long as the users say it works, it should be fine.
# However, it's not easy to maintain something like this.
# The redundant logic should be cleaned up someday.
#
# This also applies for exp-update.tester.sh, TBH I don't think that file is really needed, and it also looks like AI-generated. Just guessing though.
#####################################################################################
#
# exp-update.sh - Enhanced dotfiles update script
#
# Features:
# - Auto-detect repository structure (dots/ prefix or direct config)
# - Pull latest commits from remote
# - Rebuild packages if PKGBUILD files changed (user choice)
# - Handle config file conflicts with user choices
# - Respect .updateignore file for exclusions with flexible pattern matching:
# - Exact matches (e.g., "path/to/file")
# - Directory patterns (e.g., "path/to/dir/")
# - Wildcards (e.g., "*.log", "path/*/file")
# - Root-relative patterns (e.g., "/.config")
# - Substring matching (prefix with "**", e.g., "**temp" matches any path containing "temp")
#
set -euo pipefail
# Note: The detect_repo_structure function below auto-detects the folder layout
# Try to find the packages directory (different names in different versions)
if which pacman &>/dev/null; then
if [[ -d "${REPO_ROOT}/dist-arch" ]]; then
ARCH_PACKAGES_DIR="${REPO_ROOT}/dist-arch"
elif [[ -d "${REPO_ROOT}/arch-packages" ]]; then
ARCH_PACKAGES_DIR="${REPO_ROOT}/arch-packages"
elif [[ -d "${REPO_ROOT}/sdata/dist-arch" ]]; then
ARCH_PACKAGES_DIR="${REPO_ROOT}/sdata/dist-arch"
else
ARCH_PACKAGES_DIR="${REPO_ROOT}/dist-arch" # Default fallback
fi
fi
UPDATE_IGNORE_FILE="${REPO_ROOT}/.updateignore"
XDG_UPDATE_IGNORE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/illogical-impulse/updateignore"
#TODO: remove in future and add script to migrate to XDG path
HOME_UPDATE_IGNORE_FILE="${HOME}/.updateignore" # Legacy support
# Global arrays for cached ignore patterns (performance optimization)
declare -a IGNORE_PATTERNS=()
declare -a IGNORE_SUBSTRING_PATTERNS=()
# Track created directories to avoid redundant mkdir calls
declare -A CREATED_DIRS
# Auto-detect repository structure
detect_repo_structure() {
local found_dirs=()
# Check for dots/ prefixed structure
if [[ -d "${REPO_ROOT}/dots/.config" ]]; then
found_dirs+=("dots/.config")
[[ -d "${REPO_ROOT}/dots/.local/bin" ]] && found_dirs+=("dots/.local/bin")
[[ -d "${REPO_ROOT}/dots/.local/share" ]] && found_dirs+=("dots/.local/share")
# Check for flat structure
elif [[ -d "${REPO_ROOT}/.config" ]]; then
found_dirs+=(".config")
[[ -d "${REPO_ROOT}/.local/bin" ]] && found_dirs+=(".local/bin")
[[ -d "${REPO_ROOT}/.local/share" ]] && found_dirs+=(".local/share")
else
# Manual detection of common directories
for candidate in "dots/.config" ".config" "dots/.local/bin" ".local/bin" "dots/.local/share" ".local/share"; do
if [[ -d "${REPO_ROOT}/${candidate}" ]]; then
# Avoid duplicates
if [[ ! " ${found_dirs[*]} " =~ " ${candidate} " ]]; then
found_dirs+=("${candidate}")
fi
fi
done
fi
if [[ ${#found_dirs[@]} -eq 0 ]]; then
echo "ERROR: Could not detect repository structure" >&2
return 1
fi
echo "${found_dirs[@]}"
}
# Directories to monitor for changes (will be auto-detected)
MONITOR_DIRS=()
# Enhanced safe_read with better terminal handling
safe_read() {
local prompt="$1"
local varname="$2"
local default="${3:-}"
local input_value=""
# In non-interactive mode, use default immediately
if [[ "$NON_INTERACTIVE" == true ]]; then
if [[ -n "$default" ]]; then
printf -v "$varname" '%s' "$default"
return 0
else
log_error "Non-interactive mode requires default value for: $prompt"
return 1
fi
fi
echo -n "$prompt"
# First, try reading from stdin (supports piped input like "yes 1 |")
if read -r -t 0.1 input_value 2>/dev/null; then
# Successfully read from stdin (piped input)
if [[ -n "$input_value" ]]; then
printf -v "$varname" '%s' "$input_value"
return 0
fi
fi
# If stdin had no data, try interactive terminal
if [[ -t 0 ]]; then
# stdin is a terminal
read -r input_value
elif [[ -r /dev/tty ]]; then
# Try reading from tty
if read -r input_value </dev/tty 2>/dev/null; then
: # Success
else
input_value=""
fi
else
# No interactive terminal available
if [[ -n "$default" ]]; then
echo
log_warning "No terminal available. Using default: $default"
printf -v "$varname" '%s' "$default"
return 0
else
echo
log_error "No terminal available and no default provided"
return 1
fi
fi
if [[ -n "$input_value" ]]; then
printf -v "$varname" '%s' "$input_value"
return 0
elif [[ -n "$default" ]]; then
echo
log_warning "Empty input. Using default: $default"
printf -v "$varname" '%s' "$default"
return 0
else
echo
log_error "Input required but not provided"
return 1
fi
}
# Load and cache ignore patterns for performance
load_ignore_patterns() {
IGNORE_PATTERNS=()
IGNORE_SUBSTRING_PATTERNS=()
for ignore_file in "$UPDATE_IGNORE_FILE" "$XDG_UPDATE_IGNORE_FILE" "$HOME_UPDATE_IGNORE_FILE"; do
[[ ! -f "$ignore_file" ]] && continue
while IFS= read -r pattern || [[ -n "$pattern" ]]; do
# Skip empty lines and comments
[[ -z "$pattern" || "$pattern" =~ ^[[:space:]]*# ]] && continue
# Remove whitespace
pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$pattern" ]] && continue
# Separate substring patterns from regular patterns
if [[ "${pattern:0:2}" == "**" ]]; then
local cleaned_pattern="${pattern#\*\*}"
# Strip trailing asterisks
while [[ "$cleaned_pattern" == *"*" ]] && [[ "${cleaned_pattern: -1}" == "*" ]]; do
cleaned_pattern="${cleaned_pattern%\*}"
done
# Ensure we have a non-empty pattern
if [[ -n "$cleaned_pattern" ]]; then
IGNORE_SUBSTRING_PATTERNS+=("$cleaned_pattern")
fi
else
IGNORE_PATTERNS+=("$pattern")
fi
done < "$ignore_file"
done
if [[ "$VERBOSE" == true ]]; then
log_info "Loaded ${#IGNORE_PATTERNS[@]} ignore patterns and ${#IGNORE_SUBSTRING_PATTERNS[@]} substring patterns"
fi
}
# Optimized should_ignore using cached patterns
should_ignore() {
local file_path="$1"
local relative_path="${file_path#$HOME/}"
local repo_relative=""
if [[ "$file_path" == "$REPO_ROOT"* ]]; then
repo_relative="${file_path#$REPO_ROOT/}"
fi
# Check regular patterns
for pattern in "${IGNORE_PATTERNS[@]}"; do
# Exact match
if [[ "$relative_path" == "$pattern" ]] || [[ "$repo_relative" == "$pattern" ]]; then
return 0
fi
# Wildcard patterns (basic glob matching)
if [[ "$relative_path" == $pattern ]] || [[ "$repo_relative" == $pattern ]]; then
return 0
fi
# Directory patterns (ending with /)
if [[ "$pattern" == */ ]]; then
local dir_pattern="${pattern%/}"
if [[ "$relative_path" == "$dir_pattern"/* ]] || [[ "$repo_relative" == "$dir_pattern"/* ]]; then
return 0
fi
fi
# Root-relative patterns (starting with /)
if [[ "$pattern" == /* ]]; then
local root_pattern="${pattern#/}"
if [[ "$relative_path" == "$root_pattern" ]] || [[ "$relative_path" == "$root_pattern"/* ]] ||
[[ "$repo_relative" == "$root_pattern" ]] || [[ "$repo_relative" == "$root_pattern"/* ]]; then
return 0
fi
fi
# Patterns with wildcards - check parent directories
if [[ "$pattern" == *"*"* ]]; then
local temp_path="$relative_path"
while [[ "$temp_path" == */* ]]; do
temp_path="${temp_path%/*}"
if [[ "$temp_path" == $pattern ]]; then
return 0
fi
done
fi
done
# Check substring patterns
for substring in "${IGNORE_SUBSTRING_PATTERNS[@]}"; do
if [[ -n "$substring" && ("$file_path" == *"$substring"* || "$relative_path" == *"$substring"*) ]]; then
return 0
fi
done
return 1
}
# Efficient directory creation with caching
ensure_directory() {
local dir="$1"
# Check if already created in this run
if [[ -n "${CREATED_DIRS[$dir]:-}" ]]; then
return 0
fi
if [[ "$DRY_RUN" != true ]]; then
if [[ ! -d "$dir" ]]; then
if mkdir -p "$dir" 2>/dev/null; then
CREATED_DIRS[$dir]=1
if [[ "$VERBOSE" == true ]]; then
log_info "Created directory: $dir"
fi
else
log_error "Failed to create directory: $dir"
return 1
fi
else
CREATED_DIRS[$dir]=1
fi
else
if [[ "$VERBOSE" == true ]] || [[ -z "${CREATED_DIRS[$dir]:-}" ]]; then
log_info "[DRY-RUN] Would create directory: $dir"
fi
CREATED_DIRS[$dir]=1
fi
return 0
}
# Function to show file diff
show_diff() {
local file1="$1"
local file2="$2"
echo -e "\n${STY_CYAN}Showing differences:${STY_RST}"
echo -e "${STY_CYAN}Old file: $file1${STY_RST}"
echo -e "${STY_CYAN}New file: $file2${STY_RST}"
echo "----------------------------------------"
if command -v diff &>/dev/null; then
diff -u "$file1" "$file2" || true
else
echo "diff command not available"
fi
echo "----------------------------------------"
}
# Backup file before replacing
backup_file() {
local file="$1"
local backup_dir="${REPO_ROOT}/.update-backups"
local timestamp
timestamp=$(date +%Y%m%d-%H%M%S)
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would backup: $file"
return 0
fi
if [[ ! -f "$file" ]]; then
log_warning "File does not exist, cannot backup: $file"
return 1
fi
ensure_directory "$backup_dir" || return 1
local backup_name
local relative_name="${file#$HOME/}"
backup_name="${relative_name//\//_}.${timestamp}.bak"
if cp -p "$file" "${backup_dir}/${backup_name}" 2>/dev/null; then
log_info "Backed up to: .update-backups/${backup_name}"
return 0
else
log_error "Failed to create backup"
return 1
fi
}
# Function to handle file conflicts
handle_file_conflict() {
local repo_file="$1"
local home_file="$2"
local filename=$(basename "$home_file")
local dirname=$(dirname "$home_file")
local choice=""
local default_val="${DEFAULT_CHOICE:-6}" # Use DEFAULT_CHOICE or 6 (skip) as fallback
# In non-interactive mode, use default directly (acts like pressing Enter)
if [[ "$NON_INTERACTIVE" == true ]]; then
choice="$default_val"
log_info "Using choice $choice for: $home_file"
else
echo -e "\n${STY_YELLOW}Conflict detected:${STY_RST} $home_file"
echo "Repository version differs from your local version."
echo
echo "Choose an action:"
echo "1) Replace local file with repository version"
echo "2) Keep local file unchanged"
echo "3) Backup local file as ${filename}.old, use repository version"
echo "4) Save repository version as ${filename}.new, keep local file"
echo "5) Show diff and decide"
echo "6) Skip this file"
echo "7) Add to ignore and skip"
echo "8) Backup to .update-backups/ and replace with repository version"
echo
while true; do
if ! safe_read "Enter your choice (1-8 or name) [${default_val}]: " choice "$default_val"; then
echo
log_warning "Failed to read input. Skipping file."
return
fi
# Validate choice
if [[ "$choice" =~ ^[1-8]$ ]] || [[ "$choice" =~ ^(replace|keep|old|new|diff|skip|ignore|backup)$ ]]; then
break
else
echo "Invalid choice. Please enter 1-8 or a valid name (replace, keep, old ...)."
fi
done
fi
case $choice in
1|replace)
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would replace $home_file with repository version"
else
cp -p "$repo_file" "$home_file"
log_success "Replaced $home_file with repository version"
fi
;;
2|keep)
log_info "Keeping local version of $home_file"
;;
3|old)
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would backup local file to ${filename}.old and update with repository version"
else
mv "$home_file" "${dirname}/${filename}.old"
cp -p "$repo_file" "$home_file"
log_success "Backed up local file to ${filename}.old and updated with repository version"
fi
;;
4|new)
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would save repository version as ${filename}.new, keep local file"
else
cp -p "$repo_file" "${dirname}/${filename}.new"
log_success "Saved repository version as ${filename}.new, kept local file"
fi
;;
5|diff)
show_diff "$home_file" "$repo_file"
echo
echo "After reviewing the diff, choose:"
echo "r) Replace with repository version"
echo "k) Keep local version"
echo "b) Backup local and use repository version"
echo "n) Save repository version as .new"
echo "s) Skip this file"
echo "i) Add to ignore and skip"
echo "B) Backup to .update-backups/ and replace"
if ! safe_read "Enter your choice (r/k/b/n/s/i/B): " subchoice "s"; then
echo
log_warning "Failed to read input. Skipping file."
return
fi
case $subchoice in
r)
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would replace $home_file with repository version"
else
cp -p "$repo_file" "$home_file"
log_success "Replaced $home_file with repository version"
fi
;;
k)
log_info "Keeping local version of $home_file"
;;
b)
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would backup local file to ${filename}.old and update"
else
mv "$home_file" "${dirname}/${filename}.old"
cp -p "$repo_file" "$home_file"
log_success "Backed up local file to ${filename}.old and updated"
fi
;;
n)
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would save repository version as ${filename}.new"
else
cp -p "$repo_file" "${dirname}/${filename}.new"
log_success "Saved repository version as ${filename}.new"
fi
;;
s)
log_info "Skipping $home_file"
;;
i)
local relative_path_to_home="${home_file#$HOME/}"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would add '$relative_path_to_home' to $XDG_UPDATE_IGNORE_FILE"
else
echo "$relative_path_to_home" >>"$XDG_UPDATE_IGNORE_FILE"
log_success "Added '$relative_path_to_home' to $XDG_UPDATE_IGNORE_FILE and skipped."
fi
;;
B)
if backup_file "$home_file"; then
if [[ "$DRY_RUN" != true ]]; then
cp -p "$repo_file" "$home_file"
log_success "Replaced $home_file with repository version"
fi
fi
;;
*)
log_info "Skipping $home_file"
;;
esac
;;
6|skip)
log_info "Skipping $home_file"
;;
7|ignore)
local relative_path_to_home="${home_file#$HOME/}"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would add '$relative_path_to_home' to $XDG_UPDATE_IGNORE_FILE"
else
echo "$relative_path_to_home" >>"$XDG_UPDATE_IGNORE_FILE"
log_success "Added '$relative_path_to_home' to $XDG_UPDATE_IGNORE_FILE and skipped."
fi
;;
8|backup)
if backup_file "$home_file"; then
if [[ "$DRY_RUN" != true ]]; then
cp -p "$repo_file" "$home_file"
log_success "Replaced $home_file with repository version"
fi
fi
;;
esac
}
# Function to check if PKGBUILD has changed
check_pkgbuild_changed() {
local pkg_dir="$1"
local pkgbuild_path="${pkg_dir}/PKGBUILD"
[[ ! -f "$pkgbuild_path" ]] && return 1
local relative_path="${pkgbuild_path#$REPO_ROOT/}"
if [[ "$FORCE_CHECK" == true ]]; then
return 0
fi
# Check if HEAD@{1} exists before trying to use it
if ! git rev-parse --verify HEAD@{1} &>/dev/null; then
# Fresh clone, assume all PKGBUILDs need checking
return 0
fi
if git diff --name-only HEAD@{1} HEAD 2>/dev/null | grep -q "^${relative_path}$"; then
return 0
fi
return 1
}
# Function to list available packages
list_packages() {
local available_packages=()
local changed_packages=()
if [[ ! -d "$ARCH_PACKAGES_DIR" ]]; then
log_warning "No package directory found"
return 1
fi
for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do
if [[ -f "${pkg_dir}/PKGBUILD" ]]; then
local pkg_name=$(basename "$pkg_dir")
available_packages+=("$pkg_name")
if check_pkgbuild_changed "$pkg_dir"; then
changed_packages+=("$pkg_name")
fi
fi
done
if [[ ${#available_packages[@]} -eq 0 ]]; then
log_info "No packages found in package directory"
return 1
fi
echo -e "\n${STY_CYAN}Available packages:${STY_RST}"
for pkg in "${available_packages[@]}"; do
if [[ " ${changed_packages[*]} " =~ " ${pkg} " ]]; then
echo -e " ${STY_GREEN}${pkg}${STY_RST} (PKGBUILD changed)"
else
echo -e "${pkg}"
fi
done
if [[ ${#changed_packages[@]} -gt 0 ]]; then
echo -e "\n${STY_YELLOW}Packages with changed PKGBUILDs: ${changed_packages[*]}${STY_RST}"
fi
return 0
}
# Function to build selected packages
build_packages() {
local build_mode="$1"
local packages_to_build=()
case "$build_mode" in
"changed")
for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do
if [[ -f "${pkg_dir}/PKGBUILD" ]]; then
local pkg_name=$(basename "$pkg_dir")
if check_pkgbuild_changed "$pkg_dir"; then
packages_to_build+=("$pkg_name")
fi
fi
done
;;
"all")
for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do
if [[ -f "${pkg_dir}/PKGBUILD" ]]; then
local pkg_name=$(basename "$pkg_dir")
packages_to_build+=("$pkg_name")
fi
done
;;
"select")
echo -e "\nEnter package names separated by spaces (or 'all' for all packages):"
if ! safe_read "Packages to build: " user_selection ""; then
log_warning "Failed to read input. Skipping package builds."
return
fi
if [[ "$user_selection" == "all" ]]; then
for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do
if [[ -f "${pkg_dir}/PKGBUILD" ]]; then
local pkg_name=$(basename "$pkg_dir")
packages_to_build+=("$pkg_name")
fi
done
else
read -ra packages_to_build <<<"$user_selection"
fi
;;
esac
if [[ ${#packages_to_build[@]} -eq 0 ]]; then
log_info "No packages selected for building"
return
fi
echo -e "\n${STY_CYAN}Packages to build: ${packages_to_build[*]}${STY_RST}"
if ! safe_read "Proceed with building these packages? (Y/n): " confirm "Y"; then
log_warning "Failed to read input. Skipping package builds."
return
fi
if [[ "$confirm" =~ ^[Nn]$ ]]; then
log_info "Package building cancelled by user"
return
fi
for pkg_name in "${packages_to_build[@]}"; do
pkg_dir="${ARCH_PACKAGES_DIR}/${pkg_name}"
if [[ ! -d "$pkg_dir" || ! -f "${pkg_dir}/PKGBUILD" ]]; then
log_error "Package not found or missing PKGBUILD: $pkg_name"
continue
fi
log_info "Building package: $pkg_name"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would build package in temp directory and clean up after"
continue
fi
# Create temp build directory to avoid polluting the repo
local build_tmp_dir
build_tmp_dir=$(mktemp -d "/tmp/pkgbuild-${pkg_name}-XXXXXX")
# Copy package files to temp directory (using /. to include hidden files)
cp -r "$pkg_dir"/. "$build_tmp_dir/" || {
log_error "Failed to copy package files to temp directory"
rm -rf "$build_tmp_dir"
continue
}
cd "$build_tmp_dir" || {
log_error "Failed to change to temp build directory: $build_tmp_dir"
rm -rf "$build_tmp_dir"
continue
}
if makepkg -sCi --noconfirm; then
log_success "Successfully built and installed $pkg_name"
((rebuilt_packages++)) || true
else
log_error "Failed to build package $pkg_name"
fi
# Clean up temp build directory
cd "$REPO_ROOT" || log_die "Failed to return to repository directory"
rm -rf "$build_tmp_dir"
log_info "Cleaned up temp build directory"
# Also clean any old build artifacts in the original package directory
rm -rf "${pkg_dir}/src" "${pkg_dir}/pkg" "${pkg_dir}"/*.pkg.tar.* 2>/dev/null || true
done
if [[ $rebuilt_packages -eq 0 ]]; then
log_warning "No packages were successfully built"
else
log_success "Successfully rebuilt $rebuilt_packages package(s)"
fi
}
# Optimized function to get list of changed files
get_changed_files() {
local dir_path="$1"
if [[ "$FORCE_CHECK" == true ]]; then
find "$dir_path" -type f -print0 2>/dev/null
return
fi
# Try git-based detection first
if git rev-parse --verify HEAD@{1} &>/dev/null 2>&1; then
local temp_file
temp_file=$(mktemp)
# Get changed files with specific filters (Added, Copied, Modified, Renamed)
git diff --name-only --diff-filter=ACMR HEAD@{1} HEAD 2>/dev/null | \
while IFS= read -r file; do
local full_path="${REPO_ROOT}/${file}"
if [[ "$full_path" == "$dir_path"/* ]] && [[ -f "$full_path" ]]; then
echo "$full_path"
fi
done > "$temp_file"
if [[ -s "$temp_file" ]]; then
# Found changes via git
tr '\n' '\0' < "$temp_file"
rm -f "$temp_file"
return
fi
rm -f "$temp_file"
fi
# Fallback: check all files
find "$dir_path" -type f -print0 2>/dev/null
}
# Function to check if we have new commits
has_new_commits() {
if git rev-parse --verify HEAD@{1} &>/dev/null; then
[[ "$(git rev-parse HEAD)" != "$(git rev-parse HEAD@{1})" ]]
else
# Fresh clone or no reflog - assume we want to process files
return 0
fi
}
# Cleanup function for signal handling
cleanup_on_exit() {
local exit_code=$?
# Remove lock file
rm -f "${REPO_ROOT}/.update-lock" 2>/dev/null || true
if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != true ]]; then
echo
log_warning "Update interrupted or failed (exit code: $exit_code)"
log_info "System may be in an inconsistent state"
log_info "Run the update again to complete the process"
fi
}
# Set up signal handling and lock file
if [[ "${SOURCE_ONLY:-false}" != true ]]; then
trap cleanup_on_exit EXIT INT TERM
# Check for concurrent runs
if [[ -f "${REPO_ROOT}/.update-lock" ]]; then
# Check if the process is still running
if kill -0 "$(cat "${REPO_ROOT}/.update-lock" 2>/dev/null)" 2>/dev/null; then
log_die "Another update is already running (PID: $(cat "${REPO_ROOT}/.update-lock"))"
else
log_warning "Found stale lock file, removing..."
rm -f "${REPO_ROOT}/.update-lock"
fi
fi
# Create lock file with current PID
if [[ "$DRY_RUN" != true ]]; then
echo $$ > "${REPO_ROOT}/.update-lock"
fi
# Main script starts here
log_header "Dotfiles Update Script"
if [[ "$SKIP_NOTICE" == false ]]; then
log_warning "THIS SCRIPT IS NOT FULLY TESTED AND MAY CAUSE ISSUES!"
log_warning "It might be safer if you want to preserve your modifications and not delete added files,"
log_warning " but this can cause partial updates and therefore unexpected behavior like in #1856."
log_warning "In general, prefer \"./setup install\" for updates if available."
safe_read "Continue? (y/N): " response "N"
if [[ ! "$response" =~ ^[Yy]$ ]]; then
log_error "Update aborted by user"
exit 1
fi
fi
# Check if we're in a git repository
cd "$REPO_ROOT" || log_die "Failed to change to repository directory"
if git rev-parse --is-inside-work-tree &>/dev/null; then
log_info "Running in git repository: $(git rev-parse --show-toplevel)"
else
log_error "Not in a git repository. Please run this script from your dotfiles repository."
exit 1
fi
# Auto-detect repository structure
log_header "Detecting Repository Structure"
if detected_dirs=$(detect_repo_structure); then
read -ra MONITOR_DIRS <<<"$detected_dirs"
log_success "Detected repository structure:"
for dir in "${MONITOR_DIRS[@]}"; do
if [[ -d "${REPO_ROOT}/${dir}" ]]; then
log_info "${REPO_ROOT}/${dir}"
else
log_warning "${REPO_ROOT}/${dir} (not found, will skip)"
fi
done
else
log_die "Failed to detect repository structure. Make sure you're in the correct directory."
fi
# Load ignore patterns once at startup (performance optimization)
load_ignore_patterns
# Step 1: Pull latest commits
log_header "Pulling Latest Changes"
current_branch=$(git branch --show-current)
if [[ -z "$current_branch" ]]; then
log_warning "In detached HEAD state. Checking out main/master branch..."
if git show-ref --verify --quiet refs/heads/main; then
git checkout main
current_branch="main"
elif git show-ref --verify --quiet refs/heads/master; then
git checkout master
current_branch="master"
else
log_die "Could not find main or master branch"
fi
fi
log_info "Current branch: $current_branch"
if ! git diff --quiet || ! git diff --cached --quiet; then
log_warning "You have uncommitted changes:"
git status --short
echo
if ! safe_read "Do you want to continue? This will stash your changes. (y/N): " response "N"; then
echo
log_error "Failed to read input. Aborting."
exit 1
fi
if [[ ! "$response" =~ ^[Yy]$ ]]; then
log_die "Aborted by user"
fi
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would stash changes"
else
git stash push -m "Auto-stash before update $(date)"
log_info "Changes stashed"
fi
fi
if git remote get-url origin &>/dev/null; then
log_info "Pulling changes from origin/$current_branch..."
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would run: git pull --ff-only"
else
if git pull --ff-only; then
log_success "Successfully pulled latest changes"
git submodule update --init --recursive
# Verify we actually got new commits
if git rev-parse --verify HEAD@{1} &>/dev/null; then
if [[ "$(git rev-parse HEAD)" == "$(git rev-parse HEAD@{1})" ]]; then
log_info "Already up to date with remote"
fi
fi
else
log_warning "Failed to pull changes from remote."
log_warning "This could be due to:"
log_warning " - Network issues"
log_warning " - Uncommitted local changes (use 'git stash' first)"
log_warning " - Diverged history (may need 'git pull --rebase')"
log_info "Continuing with local repository state..."
fi
fi
else
log_warning "No remote 'origin' configured. Skipping pull operation."
log_info "This appears to be a local-only repository."
fi
# Step 2: Handle package building
rebuilt_packages=0
if [[ "$CHECK_PACKAGES" == true ]]; then
log_header "Package Management"
# Check if required Arch Linux tools are available
if ! command -v pacman &>/dev/null || ! command -v makepkg &>/dev/null; then
log_warning "Arch Linux package management tools (pacman/makepkg) not found."
log_warning "Skipping package management as this appears to be a non-Arch Linux system."
log_warning "Use -p/--packages flag only on Arch Linux systems."
PKG_TOOLS_AVAILABLE=false
else
PKG_TOOLS_AVAILABLE=true
fi
if [[ "$PKG_TOOLS_AVAILABLE" == true ]]; then
if [[ ! -d "$ARCH_PACKAGES_DIR" ]]; then
log_warning "No packages directory found (tried: dist-arch, arch-packages, sdata/dist-arch)."
log_warning "Skipping package management."
else
# Scan for changed PKGBUILDs
changed_pkgbuilds=()
for pkg_dir in "$ARCH_PACKAGES_DIR"/*/; do
if [[ -f "${pkg_dir}/PKGBUILD" ]]; then
pkg_name=$(basename "$pkg_dir")
if check_pkgbuild_changed "$pkg_dir"; then
changed_pkgbuilds+=("$pkg_name")
fi
fi
done
if [[ ${#changed_pkgbuilds[@]} -gt 0 ]]; then
log_info "Found ${#changed_pkgbuilds[@]} package(s) with changed PKGBUILDs: ${changed_pkgbuilds[*]}"
echo
echo "Package build options:"
echo "1) Build only packages with changed PKGBUILDs"
echo "2) List all packages and select which to build"
echo "3) Build all packages"
echo "4) Skip package building"
echo
if [[ "$NON_INTERACTIVE" == true ]]; then
pkg_choice="1"
log_info "Non-interactive mode: Using default package option: $pkg_choice"
elif safe_read "Choose an option (1-4): " pkg_choice "1"; then
if [[ "$VERBOSE" == true ]]; then
log_info "User selected package option: $pkg_choice"
fi
else
log_warning "Failed to read input. Skipping package building."
pkg_choice=""
fi
if [[ -n "$pkg_choice" ]]; then
case $pkg_choice in
1) build_packages "changed" ;;
2)
if list_packages; then
build_packages "select"
fi
;;
3) build_packages "all" ;;
4|*) log_info "Skipping package building" ;;
esac
fi
else
log_info "No PKGBUILDs have changed since last update."
echo
if [[ "$NON_INTERACTIVE" == true ]]; then
check_anyway="N"
log_info "Non-interactive mode: Using default for check packages anyway: $check_anyway"
elif safe_read "Do you want to check and build packages anyway? (y/N): " check_anyway "N"; then
if [[ "$VERBOSE" == true ]]; then
log_info "User chose to check packages anyway: $check_anyway"
fi
else
log_warning "Failed to read input. Skipping package management."
check_anyway=""
fi
if [[ -n "$check_anyway" && "$check_anyway" =~ ^[Yy]$ ]]; then
if list_packages; then
echo
echo "Package build options:"
echo "1) Select specific packages to build"
echo "2) Build all packages"
echo "3) Skip package building"
if safe_read "Choose an option (1-3): " build_choice "3"; then
case $build_choice in
1) build_packages "select" ;;
2) build_packages "all" ;;
3|*) log_info "Skipping package building" ;;
esac
else
log_info "Skipping package building"
fi
fi
else
log_info "Skipping package management"
fi
fi
fi
fi
else
log_header "Package Management"
log_info "Package checking disabled. Use -p or --packages flag to enable package management."
fi
# Step 3: Update configuration files
log_header "Updating Configuration Files"
process_files=false
if [[ "$FORCE_CHECK" == true ]]; then
process_files=true
log_info "Force mode: checking all configuration files"
elif has_new_commits; then
process_files=true
log_info "New commits detected: checking changed configuration files"
else
log_info "No new commits found and force mode not enabled: skipping file updates"
process_files=false
fi
if [[ "$process_files" == true ]]; then
files_processed=0
files_updated=0
files_created=0
# Count total files for progress indication (optional)
total_files=0
if [[ "$VERBOSE" == false ]] && command -v tput &>/dev/null 2>&1; then
for dir_name in "${MONITOR_DIRS[@]}"; do
repo_dir_path="${REPO_ROOT}/${dir_name}"
[[ ! -d "$repo_dir_path" ]] && continue
total_files=$((total_files + $(find "$repo_dir_path" -type f 2>/dev/null | wc -l)))
done
fi
for dir_name in "${MONITOR_DIRS[@]}"; do
repo_dir_path="${REPO_ROOT}/${dir_name}"
if [[ ! -d "$repo_dir_path" ]]; then
if [[ "$VERBOSE" == true ]]; then
log_warning "Skipping non-existent directory: $repo_dir_path"
fi
continue
fi
# FIX: Properly handle dots/ prefix mapping
if [[ "$dir_name" == dots/* ]]; then
# Strip "dots/" prefix for home directory mapping
home_subdir="${dir_name#dots/}"
home_dir_path="${HOME}/${home_subdir}"
else
# Direct structure
home_dir_path="${HOME}/${dir_name}"
fi
log_info "Processing directory: $dir_name${home_dir_path}"
ensure_directory "$home_dir_path" || continue
while IFS= read -r -d '' -u 9 repo_file; do
# Calculate relative path from the repo source directory
rel_path="${repo_file#$repo_dir_path/}"
home_file="${home_dir_path}/${rel_path}"
if should_ignore "$home_file"; then
if [[ "$VERBOSE" == true ]]; then
log_info "Ignored: $rel_path (matches ignore pattern)"
fi
continue
fi
if [[ "$VERBOSE" == true ]]; then
log_info "Processing: $rel_path"
fi
((files_processed++))
# Show progress for non-verbose mode
if [[ "$VERBOSE" == false ]] && command -v tput &>/dev/null 2>&1 && [[ $total_files -gt 0 ]]; then
printf "\r[INFO] Processing files: %d/%d" "$files_processed" "$total_files" >&2
fi
ensure_directory "$(dirname "$home_file")" || continue
if [[ -f "$home_file" ]]; then
if ! cmp -s "$repo_file" "$home_file"; then
# Clear progress line if showing
if [[ "$VERBOSE" == false ]] && command -v tput &>/dev/null 2>&1 && [[ $total_files -gt 0 ]]; then
printf "\r%*s\r" "80" "" >&2
fi
log_info "Found difference in: $rel_path"
if [[ "$DRY_RUN" == true ]]; then
log_warning "[DRY-RUN] Conflict detected (would prompt): $home_file"
((files_updated++))
else
handle_file_conflict "$repo_file" "$home_file"
((files_updated++))
fi
fi
else
if [[ "$DRY_RUN" == true ]]; then
if [[ "$VERBOSE" == true ]]; then
log_info "[DRY-RUN] Would create new file: $home_file"
fi
else
cp -p "$repo_file" "$home_file"
if [[ "$VERBOSE" == true ]]; then
log_success "Created new file: $home_file"
fi
fi
((files_created++))
fi
done 9< <(get_changed_files "$repo_dir_path") || true
echo
done
# Clear progress line if it was shown
if [[ "$VERBOSE" == false ]] && command -v tput &>/dev/null 2>&1 && [[ $total_files -gt 0 ]]; then
printf "\r%*s\r" "80" "" >&2
fi
echo
log_info "File processing summary:"
log_info "- Files processed: $files_processed"
log_info "- Files with conflicts: $files_updated"
log_info "- New files created: $files_created"
else
log_info "Skipping file updates (no changes detected and not in force mode)"
fi
# Step 4: Update script permissions
log_header "Updating Script Permissions"
if [[ -d "${HOME}/.local/bin" ]]; then
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would update script permissions in ~/.local/bin"
else
find "${HOME}/.local/bin" -type f -exec chmod +x {} \; 2>/dev/null || true
log_success "Updated ~/.local/bin script permissions"
fi
fi
log_header "Update Complete"
if [[ "$DRY_RUN" == true ]]; then
log_warning "DRY-RUN MODE: No changes were actually made"
log_info "Run without -n/--dry-run to apply changes"
else
log_success "Dotfiles update completed successfully!"
fi
echo
echo -e "${STY_CYAN}Summary:${STY_RST}"
if command -v git >/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
echo "- Repository: $(git log -1 --pretty=format:'%h - %s (%cr)' 2>/dev/null || echo 'Unknown')"
else
echo "- Repository: Unknown (git not available)"
fi
echo "- Branch: ${current_branch:-Unknown}"
echo "- Structure: ${MONITOR_DIRS[*]}"
echo "- Mode: $([ "$FORCE_CHECK" == true ] && echo "Force check" || echo "Normal")"
echo "- Package checking: $([ "$CHECK_PACKAGES" == true ] && echo "Enabled" || echo "Disabled")"
if [[ $rebuilt_packages -gt 0 ]]; then
echo "- Packages rebuilt: $rebuilt_packages"
fi
if [[ "$process_files" == true ]]; then
echo "- Files processed: $files_processed"
echo "- Files updated/conflicted: $files_updated"
echo "- New files created: $files_created"
fi
if [[ ! -f "$XDG_UPDATE_IGNORE_FILE" && ! -f "$UPDATE_IGNORE_FILE" ]]; then
echo
log_info "Tip: Create ignore files to exclude files from updates:"
echo " - Repository ignore: ${REPO_ROOT}/.updateignore"
echo " - User ignore: ${XDG_UPDATE_IGNORE_FILE}"
echo
echo "Example patterns:"
echo " *.log # Ignore all .log files"
echo " .config/personal/ # Ignore entire directory"
echo " secret-config.conf # Ignore specific file"
echo " /temp-file # Ignore from root only"
echo " **secret** # Ignore files containing 'secret'"
fi
# Show backup directory if any backups were created
if [[ -d "${REPO_ROOT}/.update-backups" ]] && [[ "$DRY_RUN" != true ]]; then
echo
log_info "Backups stored in: ${REPO_ROOT}/.update-backups/"
fi
fi
echo