From 97ed11cd42f53dd35f869ee387cf9dc101f3ba99 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 31 Dec 2025 10:23:11 +0800 Subject: [PATCH] refactor: `clean_local_snapshots` now uses an array for snapshot processing and includes a `read_key` fallback, with new tests. --- lib/clean/system.sh | 39 +++++++------ tests/system_maintenance.bats | 100 +++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index a4025fd..5960ab5 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -280,33 +280,29 @@ clean_local_snapshots() { local total_cleaned_size=0 # Estimation not possible without thin local newest_ts=0 local newest_name="" + local -a snapshots=() # Find the most recent snapshot to keep at least one version while IFS= read -r line; do # Format: com.apple.TimeMachine.2023-10-25-120000 if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then + local snap_name="${BASH_REMATCH[0]}" + snapshots+=("$snap_name") local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}" local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0") # Skip if parsing failed [[ "$snap_ts" == "0" ]] && continue if [[ "$snap_ts" -gt "$newest_ts" ]]; then newest_ts="$snap_ts" - newest_name="${BASH_REMATCH[0]}" + newest_name="$snap_name" fi fi done <<< "$snapshot_list" + [[ ${#snapshots[@]} -eq 0 ]] && return 0 [[ -z "$newest_name" ]] && return 0 - local deletable_count=0 - while IFS= read -r line; do - if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then - if [[ "${BASH_REMATCH[0]}" != "$newest_name" ]]; then - ((deletable_count++)) - fi - fi - done <<< "$snapshot_list" - - [[ $deletable_count -eq 0 ]] && return 0 + local deletable_count=$(( ${#snapshots[@]} - 1 )) + [[ $deletable_count -le 0 ]] && return 0 if [[ "$DRY_RUN" != "true" ]]; then if [[ ! -t 0 ]]; then @@ -318,7 +314,14 @@ clean_local_snapshots() { echo -e " ${GRAY}The most recent snapshot will be kept.${NC}" echo -ne " ${PURPLE}${ICON_ARROW}${NC} Remove all local snapshots except the most recent one? ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " local choice - choice=$(read_key) + if type read_key > /dev/null 2>&1; then + choice=$(read_key) + else + IFS= read -r -s -n 1 choice || choice="" + if [[ -z "$choice" || "$choice" == $'\n' || "$choice" == $'\r' ]]; then + choice="ENTER" + fi + fi if [[ "$choice" == "ENTER" ]]; then printf "\r\033[K" # Clear the prompt line else @@ -327,16 +330,12 @@ clean_local_snapshots() { fi fi - while IFS= read -r line; do + local snap_name + for snap_name in "${snapshots[@]}"; do # Format: com.apple.TimeMachine.2023-10-25-120000 - if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then - local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}" - local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0") - # Skip if parsing failed - [[ "$snap_ts" == "0" ]] && continue + if [[ "$snap_name" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then # Remove all but the most recent snapshot if [[ "${BASH_REMATCH[0]}" != "$newest_name" ]]; then - local snap_name="${BASH_REMATCH[0]}" if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Local snapshot: $snap_name ${YELLOW}dry-run${NC}" ((cleaned_count++)) @@ -353,7 +352,7 @@ clean_local_snapshots() { fi fi fi - done <<< "$snapshot_list" + done if [[ $cleaned_count -gt 0 && "$DRY_RUN" != "true" ]]; then log_success "Cleaned $cleaned_count local snapshots, kept latest" fi diff --git a/tests/system_maintenance.bats b/tests/system_maintenance.bats index c34e683..a1d3458 100644 --- a/tests/system_maintenance.bats +++ b/tests/system_maintenance.bats @@ -108,6 +108,104 @@ EOF [[ "$output" == *"No incomplete backups found"* ]] } +@test "clean_local_snapshots skips in non-interactive mode" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +tmutil() { + if [[ "$1" == "listlocalsnapshots" ]]; then + printf '%s\n' \ + "com.apple.TimeMachine.2023-10-25-120000" \ + "com.apple.TimeMachine.2023-10-24-120000" + return 0 + fi + return 0 +} +start_section_spinner(){ :; } +stop_section_spinner(){ :; } + +DRY_RUN="false" +clean_local_snapshots +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"skipping non-interactive mode"* ]] + [[ "$output" != *"Removed snapshot"* ]] +} + +@test "clean_local_snapshots keeps latest in dry-run" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +tmutil() { + if [[ "$1" == "listlocalsnapshots" ]]; then + printf '%s\n' \ + "com.apple.TimeMachine.2023-10-25-120000" \ + "com.apple.TimeMachine.2023-10-25-130000" \ + "com.apple.TimeMachine.2023-10-24-120000" + return 0 + fi + return 0 +} +start_section_spinner(){ :; } +stop_section_spinner(){ :; } +note_activity(){ :; } + +DRY_RUN="true" +clean_local_snapshots +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Local snapshot: com.apple.TimeMachine.2023-10-25-120000"* ]] + [[ "$output" == *"Local snapshot: com.apple.TimeMachine.2023-10-24-120000"* ]] + [[ "$output" != *"Local snapshot: com.apple.TimeMachine.2023-10-25-130000"* ]] +} + +@test "clean_local_snapshots uses read fallback when read_key missing" { + if ! command -v script > /dev/null 2>&1; then + skip "script not available" + fi + + local tmp_script="$BATS_TEST_TMPDIR/clean_local_snapshots_fallback.sh" + cat > "$tmp_script" <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +tmutil() { + if [[ "$1" == "listlocalsnapshots" ]]; then + printf '%s\n' \ + "com.apple.TimeMachine.2023-10-25-120000" \ + "com.apple.TimeMachine.2023-10-24-120000" + return 0 + fi + return 0 +} +start_section_spinner(){ :; } +stop_section_spinner(){ :; } +note_activity(){ :; } + +unset -f read_key + +CALL_LOG="$HOME/snapshot_calls.log" +> "$CALL_LOG" +sudo() { echo "sudo:$*" >> "$CALL_LOG"; return 0; } + +DRY_RUN="false" +clean_local_snapshots +cat "$CALL_LOG" +EOF + + run bash --noprofile --norc -c "printf '\n' | script -q /dev/null bash \"$tmp_script\"" + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped"* ]] +} + @test "clean_homebrew skips when cleaned recently" { run bash --noprofile --norc <<'EOF' @@ -1088,5 +1186,3 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Spotlight index already optimal"* ]] } - -