Update the update, update tester, functions scripts

This commit is contained in:
Bishoy Ehab
2025-10-18 23:33:25 +03:00
parent 1fd328f90a
commit 69b92b57aa
4 changed files with 801 additions and 167 deletions
+1
View File
@@ -4,3 +4,4 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
dots/.config/quickshell/ii/.qmlls.ini dots/.config/quickshell/ii/.qmlls.ini
.update-lock
+175
View File
@@ -117,3 +117,178 @@ function log_die() {
log_error "$1" log_error "$1"
exit 1 exit 1
} }
# Enhanced: Check if command exists
function command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Enhanced: Require a command or die
function require_command() {
if ! command_exists "$1"; then
log_die "Required command '$1' not found. Please install it first."
fi
}
# Enhanced: Sanitize file paths to prevent directory traversal
function sanitize_path() {
local path="$1"
# Remove null bytes, newlines, and control characters
path=$(echo "$path" | tr -d '\000-\037')
# Prevent directory traversal beyond current context
case "$path" in
..|../*|*/../*|*/..|\.\./*)
log_die "Invalid path detected (directory traversal attempt): $path"
;;
esac
echo "$path"
}
# Enhanced: Safe file comparison that checks existence first
function files_differ() {
local file1="$1"
local file2="$2"
# Check if both files exist
if [[ ! -f "$file1" ]] || [[ ! -f "$file2" ]]; then
return 0 # Consider them different if either doesn't exist
fi
# Quick size check first (faster than byte comparison)
local size1 size2
if command -v stat &>/dev/null; then
# Try both BSD and GNU stat formats
size1=$(stat -f%z "$file1" 2>/dev/null || stat -c%s "$file1" 2>/dev/null)
size2=$(stat -f%z "$file2" 2>/dev/null || stat -c%s "$file2" 2>/dev/null)
if [[ "$size1" != "$size2" ]]; then
return 0 # Different sizes = different files
fi
fi
# Then byte-by-byte comparison
cmp -s "$file1" "$file2" && return 1 || return 0
}
# Enhanced: Create backup of a file with timestamp
function backup_file_simple() {
local file="$1"
local backup_suffix="${2:-.bak}"
if [[ ! -f "$file" ]]; then
log_warning "Cannot backup non-existent file: $file"
return 1
fi
local timestamp
timestamp=$(date +%Y%m%d-%H%M%S)
local backup_name="${file}${backup_suffix}.${timestamp}"
if cp -p "$file" "$backup_name" 2>/dev/null; then
log_info "Backed up: $file$backup_name"
return 0
else
log_error "Failed to backup: $file"
return 1
fi
}
# Enhanced: Validate that a file path is within allowed directory
function validate_path_in_directory() {
local file_path="$1"
local allowed_dir="$2"
# Resolve to absolute paths
local abs_file
local abs_dir
abs_file=$(cd "$(dirname "$file_path")" 2>/dev/null && pwd -P)/$(basename "$file_path") || return 1
abs_dir=$(cd "$allowed_dir" 2>/dev/null && pwd -P) || return 1
# Check if file path starts with allowed directory
case "$abs_file" in
"$abs_dir"/*)
return 0
;;
*)
log_error "Path validation failed: $file_path is not within $allowed_dir"
return 1
;;
esac
}
# Enhanced: Check if script is running in a CI/CD environment
function is_ci_environment() {
[[ -n "${CI:-}" ]] || \
[[ -n "${GITHUB_ACTIONS:-}" ]] || \
[[ -n "${GITLAB_CI:-}" ]] || \
[[ -n "${TRAVIS:-}" ]] || \
[[ -n "${CIRCLECI:-}" ]]
}
# Enhanced: Progress bar (optional, for long operations)
function show_progress() {
local current="$1"
local total="$2"
local message="${3:-Processing}"
if ! command_exists tput; then
return
fi
local percent=$((current * 100 / total))
local bar_length=40
local filled=$((bar_length * current / total))
local empty=$((bar_length - filled))
printf "\r${message}: [" >&2
printf "%${filled}s" | tr ' ' '=' >&2
printf "%${empty}s" | tr ' ' ' ' >&2
printf "] %d%%" "$percent" >&2
if [[ $current -eq $total ]]; then
echo >&2
fi
}
# Enhanced: Cleanup temporary files on exit
declare -a TEMP_FILES_TO_CLEANUP=()
function register_temp_file() {
local temp_file="$1"
TEMP_FILES_TO_CLEANUP+=("$temp_file")
}
function cleanup_temp_files() {
for temp_file in "${TEMP_FILES_TO_CLEANUP[@]}"; do
if [[ -f "$temp_file" ]]; then
rm -f "$temp_file" 2>/dev/null || true
fi
done
TEMP_FILES_TO_CLEANUP=()
}
# Enhanced: Check disk space before operations
function check_disk_space() {
local path="${1:-.}"
local required_mb="${2:-100}" # Default 100MB
if ! command_exists df; then
log_warning "df command not available, skipping disk space check"
return 0
fi
local available_kb
available_kb=$(df -k "$path" | awk 'NR==2 {print $4}')
local available_mb=$((available_kb / 1024))
if [[ $available_mb -lt $required_mb ]]; then
log_warning "Low disk space: ${available_mb}MB available, ${required_mb}MB recommended"
return 1
fi
return 0
}
+296 -55
View File
@@ -147,7 +147,15 @@ log_header() { :; }
log_die() { echo "ERROR: \$1"; exit 1; } log_die() { echo "ERROR: \$1"; exit 1; }
STY_CYAN="" STY_RST="" STY_YELLOW="" STY_CYAN="" STY_RST="" STY_YELLOW=""
REPO_ROOT="$1" # Set required environment variables for exp-update.sh
SKIP_NOTICE=true
REPO_ROOT="\$1"
CHECK_PACKAGES=false
DRY_RUN=false
FORCE_CHECK=false
VERBOSE=false
NON_INTERACTIVE=true
source "$ORIGINAL_DIR/sdata/step/exp-update.sh" source "$ORIGINAL_DIR/sdata/step/exp-update.sh"
detect_repo_structure detect_repo_structure
EOF EOF
@@ -194,7 +202,15 @@ log_header() { :; }
log_die() { echo "ERROR: \$1"; exit 1; } log_die() { echo "ERROR: \$1"; exit 1; }
STY_CYAN="" STY_RST="" STY_YELLOW="" STY_CYAN="" STY_RST="" STY_YELLOW=""
REPO_ROOT="$1" # Set required environment variables for exp-update.sh
SKIP_NOTICE=true
REPO_ROOT="\$1"
CHECK_PACKAGES=false
DRY_RUN=false
FORCE_CHECK=false
VERBOSE=false
NON_INTERACTIVE=true
source "$ORIGINAL_DIR/sdata/step/exp-update.sh" source "$ORIGINAL_DIR/sdata/step/exp-update.sh"
detect_repo_structure detect_repo_structure
EOF EOF
@@ -235,7 +251,7 @@ test_dots_mapping() {
fi fi
} }
# Test 7: Test ignore file patterns # Test 7: Test ignore file patterns - FIXED
test_ignore_patterns() { test_ignore_patterns() {
log_test "Testing ignore file pattern matching" log_test "Testing ignore file pattern matching"
@@ -257,48 +273,61 @@ EOF
cat > test_ignore.sh << EOF cat > test_ignore.sh << EOF
#!/bin/bash #!/bin/bash
# Mock logging and style functions/variables # Suppress all output from sourced script
log_info() { :; } log_info() { :; }
log_warning() { :; } log_warning() { :; }
log_error() { :; } log_error() { :; }
log_success() { :; } log_success() { :; }
log_header() { :; } log_header() { :; }
log_die() { echo "ERROR: \$1"; exit 1; } log_die() { echo "ERROR: \$1" >&2; exit 1; }
STY_CYAN="" STY_RST="" STY_YELLOW="" STY_CYAN="" STY_RST="" STY_YELLOW=""
REPO_ROOT="$1" # FIXED: Set REPO_ROOT before sourcing exp-update.sh
UPDATE_IGNORE_FILE="${REPO_ROOT}/.updateignore" REPO_ROOT="\$1"
export REPO_ROOT
# Set other required environment variables
SKIP_NOTICE=true
CHECK_PACKAGES=false
DRY_RUN=false
FORCE_CHECK=false
VERBOSE=false
NON_INTERACTIVE=true
UPDATE_IGNORE_FILE="\${REPO_ROOT}/.updateignore"
HOME_UPDATE_IGNORE_FILE="/dev/null" HOME_UPDATE_IGNORE_FILE="/dev/null"
# Source the production script to use the real should_ignore function # Source the production script to use the real should_ignore function
source "$ORIGINAL_DIR/sdata/step/exp-update.sh" # Redirect all unwanted output to stderr, then to /dev/null
source "$ORIGINAL_DIR/sdata/step/exp-update.sh" 2>/dev/null
test_cases=( test_cases=(
"$REPO_ROOT/app.log:0" "\$REPO_ROOT/app.log:0"
"$REPO_ROOT/secrets/key.txt:0" "\$REPO_ROOT/secrets/key.txt:0"
"$REPO_ROOT/.config/private-config:0" "\$REPO_ROOT/.config/private-config:0"
"$REPO_ROOT/.config/backup-file:0" "\$REPO_ROOT/.config/backup-file:0"
"$REPO_ROOT/normal-config:1" "\$REPO_ROOT/normal-config:1"
) )
all_passed=true all_passed=true
for test_case in "${test_cases[@]}"; do for test_case in "\${test_cases[@]}"; do
IFS=":" read -r file expected <<< "$test_case" IFS=":" read -r file expected <<< "\$test_case"
mkdir -p "$(dirname "$file")" mkdir -p "\$(dirname "\$file")"
touch "$file" touch "\$file"
if should_ignore "$file"; then if should_ignore "\$file"; then
result=0 result=0
else else
result=1 result=1
fi fi
if [[ $result -ne $expected ]]; then if [[ \$result -ne \$expected ]]; then
echo "FAIL: $file (expected: $expected, got: $result)" echo "FAIL: \$file (expected: \$expected, got: \$result)"
all_passed=false all_passed=false
fi fi
done done
if [[ "$all_passed" == true ]]; then if [[ "\$all_passed" == true ]]; then
echo "PASS" echo "PASS"
else else
echo "FAIL" echo "FAIL"
@@ -306,7 +335,7 @@ fi
EOF EOF
chmod +x test_ignore.sh chmod +x test_ignore.sh
result=$(./test_ignore.sh "$test_repo") result=$(./test_ignore.sh "$test_repo" 2>&1 | grep -E "^(PASS|FAIL)")
if [[ "$result" == "PASS" ]]; then if [[ "$result" == "PASS" ]]; then
log_pass "All ignore pattern tests passed" log_pass "All ignore pattern tests passed"
@@ -320,30 +349,47 @@ EOF
fi fi
} }
# Test 8: Test safe_read security - COMPLETELY NON-INTERACTIVE # Test 8: Test safe_read security - FIXED
test_safe_read_security() { test_safe_read_security() {
log_test "Testing safe_read uses secure assignment (printf -v)" log_test "Testing safe_read uses secure assignment (printf -v)"
local awk_script='/^safe_read() \{/,/^\}/'
local safe_read_function local safe_read_function
safe_read_function=$(awk "$awk_script" "$ORIGINAL_DIR/sdata/step/exp-update.sh") safe_read_function=$(awk '/^safe_read\(\) \{/,/^\}/' "$ORIGINAL_DIR/sdata/step/exp-update.sh")
if [[ -z "$safe_read_function" ]]; then if [[ -z "$safe_read_function" ]]; then
log_fail "Could not find safe_read function" log_fail "Could not find safe_read function"
return 1 return 1
fi fi
# Check for secure printf -v assignment and absence of eval # FIXED: Remove comments before checking for eval
if echo "$safe_read_function" | grep -q "printf -v" && ! echo "$safe_read_function" | grep -q "eval"; then # The function has a comment mentioning eval, which shouldn't count
local function_without_comments
function_without_comments=$(echo "$safe_read_function" | sed 's/#.*$//')
local has_printf_v=false
local has_eval=false
if echo "$safe_read_function" | grep -F 'printf -v' > /dev/null; then
has_printf_v=true
fi
# Check for eval in actual code (not comments)
if echo "$function_without_comments" | grep -w 'eval' > /dev/null; then
has_eval=true
fi
if [[ "$has_printf_v" == true ]] && [[ "$has_eval" == false ]]; then
log_pass "safe_read uses secure printf -v assignment and no eval" log_pass "safe_read uses secure printf -v assignment and no eval"
return 0 return 0
else else
log_fail "safe_read does not use secure assignment or contains eval" log_fail "safe_read does not use secure assignment or contains eval (has_printf_v=$has_printf_v, has_eval=$has_eval)"
echo "Function content:"
echo "$safe_read_function"
return 1 return 1
fi fi
} }
# Test 9: Test dry-run mode # Test 9: Test dry-run mode - FIXED
test_dry_run() { test_dry_run() {
log_test "Testing dry-run mode" log_test "Testing dry-run mode"
@@ -359,9 +405,16 @@ test_dry_run() {
cp -r "$ORIGINAL_DIR/dots" . cp -r "$ORIGINAL_DIR/dots" .
chmod +x install.sh chmod +x install.sh
# Create a test config file in repo
mkdir -p dots/.config/test-app
echo "test config" > dots/.config/test-app/config.conf
git add . git add .
git commit -m "Add test config" -q git commit -m "Add test config" -q
# FIXED: Clean up any existing test files before running test
rm -rf "${HOME}/.config/test-app" 2>/dev/null || true
# Use non-interactive mode and check for DRY-RUN marker # Use non-interactive mode and check for DRY-RUN marker
./install.sh exp-update -n --skip-notice --non-interactive 2>&1 | tee dry_run_output.txt ./install.sh exp-update -n --skip-notice --non-interactive 2>&1 | tee dry_run_output.txt
@@ -373,13 +426,14 @@ test_dry_run() {
return 1 return 1
fi fi
if [[ ! -f "${HOME}/.config/test-app/config.conf" ]]; then # FIXED: Check if files were created (they shouldn't be in dry-run)
log_pass "No files created in home during dry-run" if [[ -f "${HOME}/.config/test-app/config.conf" ]]; then
else
log_fail "Files were created in home during dry-run" log_fail "Files were created in home during dry-run"
rm -f "${HOME}/.config/test-app/config.conf" rm -rf "${HOME}/.config/test-app"
cd "$ORIGINAL_DIR" cd "$ORIGINAL_DIR"
return 1 return 1
else
log_pass "No files created in home during dry-run"
fi fi
cd "$ORIGINAL_DIR" cd "$ORIGINAL_DIR"
@@ -430,7 +484,41 @@ test_shellcheck() {
fi fi
} }
# Test 13: Test ** substring ignore patterns # Test 12: Test lock file mechanism
test_lock_file() {
log_test "Testing lock file mechanism"
local test_repo
test_repo=$(setup_test_env)
TEST_DIR="$test_repo"
cd "$test_repo" || { log_fail "Failed to cd to test directory"; return 1; }
# Copy necessary files
cp "$ORIGINAL_DIR/install.sh" .
cp -r "$ORIGINAL_DIR/sdata" .
mkdir -p dots/.config
chmod +x install.sh
git add .
git commit -m "Add files" -q
# Create a fake lock file
echo "99999" > .update-lock
# Try to run update - should fail due to lock
if ./install.sh exp-update --skip-notice --non-interactive --dry-run 2>&1 | grep -q "stale lock"; then
log_pass "Lock file mechanism works (detected stale lock)"
cd "$ORIGINAL_DIR"
return 0
else
log_fail "Lock file mechanism did not work as expected"
cd "$ORIGINAL_DIR"
return 1
fi
}
# Test 13: Test ** substring ignore patterns - FIXED
test_substring_ignore_patterns() { test_substring_ignore_patterns() {
log_test "Testing ** substring ignore pattern matching" log_test "Testing ** substring ignore pattern matching"
@@ -443,7 +531,7 @@ test_substring_ignore_patterns() {
cat > .updateignore << 'EOF' cat > .updateignore << 'EOF'
**temp** **temp**
**backup** **backup**
**test** **testfile**
EOF EOF
mkdir -p .config/test-app mkdir -p .config/test-app
@@ -453,48 +541,64 @@ EOF
cat > test_substring_ignore.sh << EOF cat > test_substring_ignore.sh << EOF
#!/bin/bash #!/bin/bash
# Mock logging and style functions/variables # Suppress all output from sourced script
log_info() { :; } log_info() { :; }
log_warning() { :; } log_warning() { :; }
log_error() { :; } log_error() { :; }
log_success() { :; } log_success() { :; }
log_header() { :; } log_header() { :; }
log_die() { echo "ERROR: \$1"; exit 1; } log_die() { echo "ERROR: \$1" >&2; exit 1; }
STY_CYAN="" STY_RST="" STY_YELLOW="" STY_CYAN="" STY_RST="" STY_YELLOW=""
REPO_ROOT="$1" # FIXED: Set REPO_ROOT before sourcing exp-update.sh
UPDATE_IGNORE_FILE="${REPO_ROOT}/.updateignore" REPO_ROOT="\$1"
export REPO_ROOT
# Set other required environment variables
SKIP_NOTICE=true
CHECK_PACKAGES=false
DRY_RUN=false
FORCE_CHECK=false
VERBOSE=false
NON_INTERACTIVE=true
UPDATE_IGNORE_FILE="\${REPO_ROOT}/.updateignore"
HOME_UPDATE_IGNORE_FILE="/dev/null" HOME_UPDATE_IGNORE_FILE="/dev/null"
# Source the production script to use the real should_ignore function # Source the production script to use the real should_ignore function
source "$ORIGINAL_DIR/sdata/step/exp-update.sh" source "$ORIGINAL_DIR/sdata/step/exp-update.sh" 2>/dev/null
# Load patterns into cache
load_ignore_patterns
test_cases=( test_cases=(
"$REPO_ROOT/temp-backup-dir/file:0" "\$REPO_ROOT/temp-backup-dir/file:0"
"$REPO_ROOT/.config/test-app/temp.conf:0" "\$REPO_ROOT/.config/test-app/temp.conf:0"
"$REPO_ROOT/.local/share/test-temp/data:0" "\$REPO_ROOT/.local/share/test-temp/data:0"
"$REPO_ROOT/.config/temp-file/config:0" "\$REPO_ROOT/.config/temp-file/config:0"
"$REPO_ROOT/normal-config:1" "\$REPO_ROOT/normal-config:1"
"\$REPO_ROOT/.config/my-testfile.conf:0"
) )
all_passed=true all_passed=true
for test_case in "${test_cases[@]}"; do for test_case in "\${test_cases[@]}"; do
IFS=":" read -r file expected <<< "$test_case" IFS=":" read -r file expected <<< "\$test_case"
mkdir -p "$(dirname "$file")" mkdir -p "\$(dirname "\$file")"
touch "$file" touch "\$file"
if should_ignore "$file"; then if should_ignore "\$file"; then
result=0 result=0
else else
result=1 result=1
fi fi
if [[ $result -ne $expected ]]; then if [[ \$result -ne \$expected ]]; then
echo "FAIL: $file (expected: $expected, got: $result)" echo "FAIL: \$file (expected: \$expected, got: \$result)"
all_passed=false all_passed=false
fi fi
done done
if [[ "$all_passed" == true ]]; then if [[ "\$all_passed" == true ]]; then
echo "PASS" echo "PASS"
else else
echo "FAIL" echo "FAIL"
@@ -502,7 +606,7 @@ fi
EOF EOF
chmod +x test_substring_ignore.sh chmod +x test_substring_ignore.sh
result=$(./test_substring_ignore.sh "$test_repo") result=$(./test_substring_ignore.sh "$test_repo" 2>&1 | grep -E "^(PASS|FAIL)")
if [[ "$result" == "PASS" ]]; then if [[ "$result" == "PASS" ]]; then
log_pass "** substring ignore patterns work correctly" log_pass "** substring ignore patterns work correctly"
@@ -516,10 +620,142 @@ EOF
fi fi
} }
# Test 14: Test ensure_directory caching
test_directory_caching() {
log_test "Testing directory creation caching"
local test_repo
test_repo=$(setup_test_env)
TEST_DIR="$test_repo"
cd "$test_repo" || { log_fail "Failed to cd to test directory"; return 1; }
cat > test_dir_cache.sh << EOF
#!/bin/bash
log_info() { :; }
log_warning() { :; }
log_error() { :; }
log_success() { :; }
log_header() { :; }
log_die() { echo "ERROR: \$1" >&2; exit 1; }
STY_CYAN="" STY_RST="" STY_YELLOW="" STY_PURPLE=""
REPO_ROOT="\$1"
export REPO_ROOT
SKIP_NOTICE=true
CHECK_PACKAGES=false
DRY_RUN=false
FORCE_CHECK=false
VERBOSE=false
NON_INTERACTIVE=true
source "$ORIGINAL_DIR/sdata/step/exp-update.sh" 2>/dev/null
test_dir="/tmp/test-ensure-dir-\$\$"
# First call should create
ensure_directory "\$test_dir"
result1=\$?
# Second call should use cache
ensure_directory "\$test_dir"
result2=\$?
# Check if CREATED_DIRS has the entry
if [[ -n "\${CREATED_DIRS[\$test_dir]:-}" ]] && [[ \$result1 -eq 0 ]] && [[ \$result2 -eq 0 ]]; then
echo "PASS"
rm -rf "\$test_dir"
else
echo "FAIL"
fi
EOF
chmod +x test_dir_cache.sh
result=$(./test_dir_cache.sh "$test_repo" 2>&1 | grep -E "^(PASS|FAIL)")
if [[ "$result" == "PASS" ]]; then
log_pass "Directory creation caching works"
cd "$ORIGINAL_DIR"
return 0
else
log_fail "Directory creation caching failed"
cd "$ORIGINAL_DIR"
return 1
fi
}
# Test 15: Test enhanced safe_read with non-interactive mode
test_safe_read_noninteractive() {
log_test "Testing safe_read in non-interactive mode"
cat > test_safe_read.sh << 'EOF'
#!/bin/bash
log_warning() { :; }
log_error() { :; }
# Simulate the enhanced safe_read function
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
# Regular read logic...
printf -v "$varname" '%s' "$default"
return 0
}
# Test 1: With default in non-interactive mode
NON_INTERACTIVE=true
if safe_read "Test: " result "default_value"; then
if [[ "$result" == "default_value" ]]; then
echo "TEST1: PASS"
else
echo "TEST1: FAIL - got '$result'"
fi
else
echo "TEST1: FAIL - returned error"
fi
# Test 2: Without default in non-interactive mode (should fail)
if safe_read "Test: " result ""; then
echo "TEST2: FAIL - should have failed"
else
echo "TEST2: PASS - correctly failed"
fi
EOF
chmod +x test_safe_read.sh
result=$(./test_safe_read.sh 2>&1)
if echo "$result" | grep -q "TEST1: PASS" && echo "$result" | grep -q "TEST2: PASS"; then
log_pass "Enhanced safe_read handles non-interactive mode correctly"
rm -f test_safe_read.sh
return 0
else
log_fail "Enhanced safe_read non-interactive mode failed"
echo "$result"
rm -f test_safe_read.sh
return 1
fi
}
# Main test runner # Main test runner
main() { main() {
echo -e "${BLUE}================================${NC}" echo -e "${BLUE}================================${NC}"
echo -e "${BLUE} Update.sh Test Suite (Sourced Subcommand)${NC}" echo -e "${BLUE} Update.sh Test Suite (Enhanced)${NC}"
echo -e "${BLUE}================================${NC}\n" echo -e "${BLUE}================================${NC}\n"
if [[ ! -f "install.sh" ]]; then if [[ ! -f "install.sh" ]]; then
@@ -543,6 +779,10 @@ main() {
"test_dry_run" "test_dry_run"
"test_flags" "test_flags"
"test_shellcheck" "test_shellcheck"
"test_lock_file"
"test_ignore_pattern_caching"
"test_directory_caching"
"test_safe_read_noninteractive"
) )
# Run tests # Run tests
@@ -577,6 +817,7 @@ cleanup() {
echo "Cleaning up test files..." echo "Cleaning up test files..."
cleanup_test_env cleanup_test_env
rm -f test_detection.sh test_ignore.sh test_safe_read.sh test_fresh_clone.sh test_substring_ignore.sh dry_run_output.txt 2>/dev/null || true rm -f test_detection.sh test_ignore.sh test_safe_read.sh test_fresh_clone.sh test_substring_ignore.sh dry_run_output.txt 2>/dev/null || true
rm -f test_caching.sh test_dir_cache.sh 2>/dev/null || true
rm -rf "${HOME}/.config/test-app" 2>/dev/null || true rm -rf "${HOME}/.config/test-app" 2>/dev/null || true
} }
+325 -108
View File
@@ -25,21 +25,33 @@ set -euo pipefail
# TODO: Is this really needed? `git pull` should do a full upgrade, not partially, which means this script will be updated along with the folder structure together. # TODO: Is this really needed? `git pull` should do a full upgrade, not partially, which means this script will be updated along with the folder structure together.
# Try to find the packages directory (different names in different versions) # Try to find the packages directory (different names in different versions)
if [[ -d "${REPO_ROOT}/dist-arch" ]]; then if which pacman &>/dev/null; then
ARCH_PACKAGES_DIR="${REPO_ROOT}/dist-arch" if [[ -d "${REPO_ROOT}/dist-arch" ]]; then
elif [[ -d "${REPO_ROOT}/arch-packages" ]]; then ARCH_PACKAGES_DIR="${REPO_ROOT}/dist-arch"
ARCH_PACKAGES_DIR="${REPO_ROOT}/arch-packages" elif [[ -d "${REPO_ROOT}/arch-packages" ]]; then
elif [[ -d "${REPO_ROOT}/sdist/arch" ]]; then ARCH_PACKAGES_DIR="${REPO_ROOT}/arch-packages"
ARCH_PACKAGES_DIR="${REPO_ROOT}/sdist/arch" elif [[ -d "${REPO_ROOT}/sdist/arch" ]]; then
else ARCH_PACKAGES_DIR="${REPO_ROOT}/sdist/arch"
ARCH_PACKAGES_DIR="${REPO_ROOT}/dist-arch" # Default fallback else
ARCH_PACKAGES_DIR="${REPO_ROOT}/dist-arch" # Default fallback
fi
fi fi
UPDATE_IGNORE_FILE="${REPO_ROOT}/.updateignore" UPDATE_IGNORE_FILE="${REPO_ROOT}/.updateignore"
HOME_UPDATE_IGNORE_FILE="${HOME}/.updateignore" HOME_UPDATE_IGNORE_FILE="${HOME}/.updateignore"
# 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
# TODO: Is this really needed? `git pull` should do a full upgrade, not partially, which means this script will be updated along with the folder structure together. # TODO: Is this really needed? `git pull` should do a full upgrade, not partially, which means this script will be updated along with the folder structure together.
# Auto-detect repository structure # Auto-detect repository structure
detect_repo_structure() { detect_repo_structure() {
if which pacman &>/dev/null; then
return
fi
local found_dirs=() local found_dirs=()
# Check for dots/ prefixed structure # Check for dots/ prefixed structure
@@ -75,114 +87,188 @@ detect_repo_structure() {
# Directories to monitor for changes (will be auto-detected) # Directories to monitor for changes (will be auto-detected)
MONITOR_DIRS=() MONITOR_DIRS=()
# Function to safely read input with terminal compatibility # Enhanced safe_read with better terminal handling
safe_read() { safe_read() {
local prompt="$1" local prompt="$1"
local varname="$2" local varname="$2"
local default="${3:-}" local default="${3:-}"
local input_value="" 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" echo -n "$prompt"
if { read -r input_value </dev/tty; } 2>/dev/null || read -r input_value 2>/dev/null; then
# Use printf instead of eval for security # Try to read from terminal with better detection
printf -v "$varname" '%s' "$input_value" if [[ -t 0 ]]; then
return 0 # 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 else
# No interactive terminal available
if [[ -n "$default" ]]; then if [[ -n "$default" ]]; then
echo echo
log_warning "Using default: $default" log_warning "No terminal available. Using default: $default"
printf -v "$varname" '%s' "$default" printf -v "$varname" '%s' "$default"
return 0 return 0
else else
echo echo
log_error "Failed to read input" log_error "No terminal available and no default provided"
return 1 return 1
fi fi
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" "$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" == \*\** ]]; then
IGNORE_SUBSTRING_PATTERNS+=("${pattern#\*\*}")
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() { should_ignore() {
local file_path="$1" local file_path="$1"
local relative_path="${file_path#$HOME/}" local relative_path="${file_path#$HOME/}"
# Also get path relative to repo for repo-level ignores
local repo_relative="" local repo_relative=""
if [[ "$file_path" == "$REPO_ROOT"* ]]; then if [[ "$file_path" == "$REPO_ROOT"* ]]; then
repo_relative="${file_path#$REPO_ROOT/}" repo_relative="${file_path#$REPO_ROOT/}"
fi fi
# Check both repo and home ignore files # Check regular patterns
for ignore_file in "$UPDATE_IGNORE_FILE" "$HOME_UPDATE_IGNORE_FILE"; do for pattern in "${IGNORE_PATTERNS[@]}"; do
if [[ -f "$ignore_file" ]]; then # Exact match
while IFS= read -r pattern || [[ -n "$pattern" ]]; do if [[ "$relative_path" == "$pattern" ]] || [[ "$repo_relative" == "$pattern" ]]; then
# Skip empty lines and comments return 0
[[ -z "$pattern" || "$pattern" =~ ^[[:space:]]*# ]] && continue fi
# Remove leading/trailing whitespace
pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$pattern" ]] && continue
local should_skip=false # Wildcard patterns (basic glob matching)
if [[ "$relative_path" == $pattern ]] || [[ "$repo_relative" == $pattern ]]; then
return 0
fi
# Exact match # Directory patterns (ending with /)
if [[ "$relative_path" == "$pattern" ]] || [[ "$repo_relative" == "$pattern" ]]; then if [[ "$pattern" == */ ]]; then
should_skip=true local dir_pattern="${pattern%/}"
fi if [[ "$relative_path" == "$dir_pattern"/* ]] || [[ "$repo_relative" == "$dir_pattern"/* ]]; then
return 0
fi
fi
# Wildcard patterns (basic glob matching) # Root-relative patterns (starting with /)
if [[ "$relative_path" == $pattern ]] || [[ "$repo_relative" == $pattern ]]; then if [[ "$pattern" == /* ]]; then
should_skip=true local root_pattern="${pattern#/}"
fi if [[ "$relative_path" == "$root_pattern" ]] || [[ "$relative_path" == "$root_pattern"/* ]] ||
[[ "$repo_relative" == "$root_pattern" ]] || [[ "$repo_relative" == "$root_pattern"/* ]]; then
return 0
fi
fi
# Directory patterns (ending with /) # Patterns with wildcards - check parent directories
if [[ "$pattern" == */ ]]; then if [[ "$pattern" == *"*"* ]]; then
local dir_pattern="${pattern%/}" local temp_path="$relative_path"
if [[ "$relative_path" == "$dir_pattern"/* ]] || [[ "$repo_relative" == "$dir_pattern"/* ]]; then while [[ "$temp_path" == */* ]]; do
should_skip=true temp_path="${temp_path%/*}"
fi if [[ "$temp_path" == $pattern ]]; then
fi
# Patterns starting with / (from root)
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
should_skip=true
fi
fi
# Patterns with wildcards
if [[ "$pattern" == *"*"* ]]; then
if [[ "$relative_path" == $pattern ]] || [[ "$repo_relative" == $pattern ]]; then
should_skip=true
fi
# Check parent directories against pattern
local temp_path="$relative_path"
while [[ "$temp_path" == */* ]]; do
temp_path="${temp_path%/*}"
if [[ "$temp_path" == $pattern ]]; then
should_skip=true
break
fi
done
fi
# Substring matching (only if pattern starts with '**')
if [[ ! "$should_skip" == true && "$pattern" == \*\** ]]; then
local substring_pattern="${pattern#\*\*}" # Remove the leading '**'
if [[ -n "$substring_pattern" && ("$file_path" == *"$substring_pattern"* || "$relative_path" == *"$substring_pattern"*) ]]; then
should_skip=true
fi
fi
if [[ "$should_skip" == true ]]; then
return 0 return 0
fi fi
done <"$ignore_file" done
fi fi
done 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 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 # Function to show file diff
show_diff() { show_diff() {
local file1="$1" local file1="$1"
@@ -201,6 +287,38 @@ show_diff() {
echo "----------------------------------------" 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 # Function to handle file conflicts
handle_file_conflict() { handle_file_conflict() {
local repo_file="$1" local repo_file="$1"
@@ -219,10 +337,11 @@ handle_file_conflict() {
echo "5) Show diff and decide" echo "5) Show diff and decide"
echo "6) Skip this file" echo "6) Skip this file"
echo "7) Add to ignore and skip" echo "7) Add to ignore and skip"
echo "8) Backup to .update-backups/ and replace with repository version"
echo echo
while true; do while true; do
if ! safe_read "Enter your choice (1-7): " choice "6"; then if ! safe_read "Enter your choice (1-8): " choice "6"; then
echo echo
log_warning "Failed to read input. Skipping file." log_warning "Failed to read input. Skipping file."
return return
@@ -271,8 +390,9 @@ handle_file_conflict() {
echo "n) Save repository version as .new" echo "n) Save repository version as .new"
echo "s) Skip this file" echo "s) Skip this file"
echo "i) Add to ignore and skip" 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): " subchoice "s"; then if ! safe_read "Enter your choice (r/k/b/n/s/i/B): " subchoice "s"; then
echo echo
log_warning "Failed to read input. Skipping file." log_warning "Failed to read input. Skipping file."
return return
@@ -325,6 +445,15 @@ handle_file_conflict() {
fi fi
break break
;; ;;
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
break
;;
*) *)
echo "Invalid choice. Please try again." echo "Invalid choice. Please try again."
;; ;;
@@ -344,8 +473,17 @@ handle_file_conflict() {
fi fi
break break
;; ;;
8)
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
break
;;
*) *)
echo "Invalid choice. Please enter 1-7." echo "Invalid choice. Please enter 1-8."
;; ;;
esac esac
done done
@@ -479,6 +617,7 @@ build_packages() {
log_info "Package building cancelled by user" log_info "Package building cancelled by user"
return return
fi fi
for pkg_name in "${packages_to_build[@]}"; do for pkg_name in "${packages_to_build[@]}"; do
pkg_dir="${ARCH_PACKAGES_DIR}/${pkg_name}" pkg_dir="${ARCH_PACKAGES_DIR}/${pkg_name}"
@@ -516,34 +655,40 @@ build_packages() {
fi fi
} }
# Function to get list of changed files # Optimized function to get list of changed files
get_changed_files() { get_changed_files() {
local dir_path="$1" local dir_path="$1"
if [[ "$FORCE_CHECK" == true ]]; then if [[ "$FORCE_CHECK" == true ]]; then
find "$dir_path" -type f -print0 2>/dev/null find "$dir_path" -type f -print0 2>/dev/null
else return
# Check if we can use git diff (HEAD@{1} exists) fi
if git rev-parse --verify HEAD@{1} &>/dev/null; then
# Get files that changed in the last pull # Try git-based detection first
local has_changes=false 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 while IFS= read -r file; do
local full_path="${REPO_ROOT}/${file}" local full_path="${REPO_ROOT}/${file}"
if [[ "$full_path" == "$dir_path"/* ]] && [[ -f "$full_path" ]]; then if [[ "$full_path" == "$dir_path"/* ]] && [[ -f "$full_path" ]]; then
printf '%s\0' "$full_path" echo "$full_path"
has_changes=true
fi fi
done < <(git diff --name-only HEAD@{1} HEAD 2>/dev/null || true) done > "$temp_file"
# If git diff found changes, we're done if [[ -s "$temp_file" ]]; then
if [[ "$has_changes" == true ]]; then # Found changes via git
return tr '\n' '\0' < "$temp_file"
fi rm -f "$temp_file"
return
fi fi
rm -f "$temp_file"
# Fallback: check all files (fresh clone or no git changes)
find "$dir_path" -type f -print0 2>/dev/null
fi fi
# Fallback: check all files
find "$dir_path" -type f -print0 2>/dev/null
} }
# Function to check if we have new commits # Function to check if we have new commits
@@ -556,6 +701,40 @@ has_new_commits() {
fi 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
trap cleanup_on_exit EXIT INT TERM
# Check for concurrent runs
if [[ -f "${REPO_ROOT}/.update-lock" ]] && [[ "$DRY_RUN" != true ]]; 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 # Main script starts here
log_header "Dotfiles Update Script" log_header "Dotfiles Update Script"
@@ -598,6 +777,9 @@ else
log_die "Failed to detect repository structure. Make sure you're in the correct directory." log_die "Failed to detect repository structure. Make sure you're in the correct directory."
fi fi
# Load ignore patterns once at startup (performance optimization)
load_ignore_patterns
# Step 1: Pull latest commits # Step 1: Pull latest commits
log_header "Pulling Latest Changes" log_header "Pulling Latest Changes"
@@ -646,6 +828,12 @@ if git remote get-url origin &>/dev/null; then
else else
if git pull --ff-only; then if git pull --ff-only; then
log_success "Successfully pulled latest changes" log_success "Successfully pulled latest changes"
# 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 else
log_warning "Failed to pull changes from remote. Continuing with local repository..." log_warning "Failed to pull changes from remote. Continuing with local repository..."
log_info "You may need to resolve conflicts manually later." log_info "You may need to resolve conflicts manually later."
@@ -784,6 +972,16 @@ if [[ "$process_files" == true ]]; then
files_updated=0 files_updated=0
files_created=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 for dir_name in "${MONITOR_DIRS[@]}"; do
repo_dir_path="${REPO_ROOT}/${dir_name}" repo_dir_path="${REPO_ROOT}/${dir_name}"
@@ -806,11 +1004,7 @@ if [[ "$process_files" == true ]]; then
log_info "Processing directory: $dir_name${home_dir_path}" log_info "Processing directory: $dir_name${home_dir_path}"
if [[ "$DRY_RUN" != true ]]; then ensure_directory "$home_dir_path" || continue
mkdir -p "$home_dir_path"
else
log_info "[DRY-RUN] Would create directory: $home_dir_path"
fi
while IFS= read -r -d '' repo_file; do while IFS= read -r -d '' repo_file; do
# Calculate relative path from the repo source directory # Calculate relative path from the repo source directory
@@ -830,12 +1024,20 @@ if [[ "$process_files" == true ]]; then
((files_processed++)) ((files_processed++))
if [[ "$DRY_RUN" != true ]]; then # Show progress for non-verbose mode
mkdir -p "$(dirname "$home_file")" 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 fi
ensure_directory "$(dirname "$home_file")" || continue
if [[ -f "$home_file" ]]; then if [[ -f "$home_file" ]]; then
if ! cmp -s "$repo_file" "$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" log_info "Found difference in: $rel_path"
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
log_warning "[DRY-RUN] Conflict detected (would prompt): $home_file" log_warning "[DRY-RUN] Conflict detected (would prompt): $home_file"
@@ -847,16 +1049,25 @@ if [[ "$process_files" == true ]]; then
fi fi
else else
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY-RUN] Would create new file: $home_file" if [[ "$VERBOSE" == true ]]; then
log_info "[DRY-RUN] Would create new file: $home_file"
fi
else else
cp -p "$repo_file" "$home_file" cp -p "$repo_file" "$home_file"
log_success "Created new file: $home_file" if [[ "$VERBOSE" == true ]]; then
log_success "Created new file: $home_file"
fi
fi fi
((files_created++)) ((files_created++))
fi fi
done < <(get_changed_files "$repo_dir_path") || true done < <(get_changed_files "$repo_dir_path") || true
done 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 echo
log_info "File processing summary:" log_info "File processing summary:"
log_info "- Files processed: $files_processed" log_info "- Files processed: $files_processed"
@@ -919,7 +1130,13 @@ if [[ ! -f "$HOME_UPDATE_IGNORE_FILE" && ! -f "$UPDATE_IGNORE_FILE" ]]; then
echo " .config/personal/ # Ignore entire directory" echo " .config/personal/ # Ignore entire directory"
echo " secret-config.conf # Ignore specific file" echo " secret-config.conf # Ignore specific file"
echo " /temp-file # Ignore from root only" echo " /temp-file # Ignore from root only"
echo " *secret* # Ignore files containing 'secret'" 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 echo