4
\$\begingroup\$

Time Machine is a useful backup service for Mac, but deleting multiple backups is cumbersome. This script allows mass deletion of unwanted backups while allowing specified "important" backups to remain.

I recently ran out of space on an external drive I was using as a Time Machine backup location, and though there were several old backups I didn't need any more, there were a few specific backups I needed to preserve. Since backup deletion through Time Machine's UI is clunky and limited to individual deletions, I looked around for a script to do what I needed. After not finding a perfect match, I wrote this.

I have been considering posting this elsewhere so people in my situation could benefit from it, but since I have no experience with bash or the nitty-gritty of Time Machine backups, I wanted to make sure I wasn't missing anything obvious that could potentially cause someone to lose data.

I've tested it pretty thoroughly on my setup (OSX 10.10.5 with a single Time Machine archive on an external, non-Time-Capsule drive), but I'm new to this and shouldn't disseminate code dealing in such a sensitive area without being sure it's (reasonably) bulletproof.

#!/bin/bash

sudo_command="sudo bash $(cd "$(dirname "$0")" && pwd)/$(basename ${BASH_SOURCE[0]})"

while [[ $# -gt 0 ]]
do
    option_name="$1"
    case "$option_name" in
        #This many backups will be deleted
        -a|--amount-to-delete)
            shift
            amount_of_backups_to_delete="$1"
        ;;

        #The default is to delete oldest to newest
        -n|--newest-to-oldest)
            delete_newest_to_oldest=true
        ;;

        #Test run
        -t|--test-run)
            test_run=true
        ;;

        #Print help
        -h|--help)
        echo "This script deletes a certain amount of Time Machine backups that are no longer needed. If there are important backups you never want to delete, add them to the saved_backup_names array in this file."
        echo "OPTIONS"
        echo "-a (--amount-to-delete) <n>"
        echo "    This required option  specifies how many backups should be deleted. n should be an integer greater than 0 or \"all\" to delete all unwanted backups."
        echo "-n (--newest-to-oldest)"
        echo "    This optional option changes the delete order from the default of oldest to newest."
        echo "-t (--test-run)"
        echo "    This optional option runs the entire script as normal but substitutes the \"delete\" verb of the deletion command with a fake verb so the deletions do not occur."
        echo "    This is the closest you can get to seeing what will happen without actually performing the deletes."
        echo
        echo "EXAMPLE USAGE"
        echo "    $sudo_command -a 4 -t"
        echo "    $sudo_command -a all --newest-to-oldest"
        exit
        ;;
        *)
        #Unrecognized option
        echo "'$option_name' is not a valid option."
        ;;
    esac
    #Move to the next option
    shift
done

if [[ "$EUID" -ne 0 ]]
then
  echo "You must run this as root. Use this command:"
  echo "    $sudo_command $@"
  exit
fi

#If --amount-to-delete wasn't provided, exit
if [[ -z $amount_of_backups_to_delete ]]
then
    echo "Missing required option: --amount-to-delete <n>"
    exit
fi

#If --newest-to-oldest wasn't provided, set the flag to false
if [[ -z $delete_newest_to_oldest ]]
then
    delete_newest_to_oldest=false
fi

#If --test-run wasn't provided, set the flag to false
if [[ -z $test_run ]]
then
    test_run=false
fi

#Array of backup names for backups that will NOT be deleted
#If there is a benchmark backup that should never be deleted by this script, add it to this array
saved_backup_names=(
    "2014-05-17-094303"
    "2014-06-15-093106"
    "2014-06-29-133847"
    "2015-01-02-145324"
    "2015-02-18-103203"
    "2015-07-25-001743"
    "2015-09-12-123932"
    "2015-12-12-025214"
    "2016-12-16-044517"
)

#Get an array of absolute paths to all remaining backups
all_backup_paths=($(/usr/bin/tmutil listbackups))
if [[ $amount_of_backups_to_delete == "all" ]]
then
    amount_of_backups_to_delete="${#all_backup_paths[@]}"
elif [[ $amount_of_backups_to_delete -lt 1 ]]
then
    echo "--amount-to-delete must be > 0"
    exit
fi

#Build an array of paths to the backups that will be deleted so the list can be approved by the user
amount_of_backups_added=0
for ((i = 0; i < ${#all_backup_paths[@]} && $amount_of_backups_added < $amount_of_backups_to_delete; i++))
do
    #Offset index from the last item if the newest backups should be deleted first
    index=$i
    if [[ $delete_newest_to_oldest = true ]]
    then
        index=`expr ${#all_backup_paths[@]} - 1 - $index`
    fi

    backup_name=$(basename ${all_backup_paths[$index]})
    able_to_delete=true
    for name in "${saved_backup_names[@]}"
    do
        #If the current backup trying to be deleted is in the list of saved names, don't delete it
        if [[ $name == $backup_name ]]
        then
            able_to_delete=false
        fi
    done

    if [[ $able_to_delete = true ]]
    then
        ((amount_of_backups_added++))
        to_delete_backup_paths+=("${all_backup_paths[$index]}")
    fi
done

#Print a list of the protected and to-be-deleted backups and prompt for confirmation
confirmation_message="I have reviewed the deletion list."

echo "These backups are protected. They will not be deleted."
echo "--------------------------------------------------------------------------------"
for name in "${saved_backup_names[@]}"
do
  echo "$name"
done
echo

echo "These backups will be deleted."
echo "--------------------------------------------------------------------------------"
for backup_path in "${to_delete_backup_paths[@]}"
do
    basename "$backup_path"
done
echo

read -p "Enter \"$confirmation_message\" sans quotes to start the deletion.  "
echo

if [[ $REPLY == $confirmation_message ]]
then
    for ((i = 0; i < ${#to_delete_backup_paths[@]}; i++))
    do
        date=$(date)
        echo "[$date]    Starting deletion for ${to_delete_backup_paths[$i]}"

        if [[ $test_run = true ]]
        then
            sudo time /usr/bin/tmutil this_is_a_test_run ${to_delete_backup_paths[$i]}
        else
            sudo time /usr/bin/tmutil delete ${to_delete_backup_paths[$i]}
        fi

        date=$(date)
        echo "[$date]    Finished"
    done
else
    echo "The confirmation message didn't match, so nothing was deleted."
fi
\$\endgroup\$
  • 2
    \$\begingroup\$ Doesn't Time Machine automatically delete old backups from the same machine to free up space as necessary? You should only need to be concerned about backups of other machines on the same volume, right? \$\endgroup\$ – 200_success Dec 17 '16 at 6:13
  • 1
    \$\begingroup\$ @200_success Won't that destroy the back-ups the author wants to keep as well? This is a work-round to keep those in place. \$\endgroup\$ – Mast Dec 17 '16 at 12:55
  • \$\begingroup\$ @200_success You are both correct. The oldest backups get automatically overwritten if there is not enough space for a new one, but there is no way to preserve certain "benchmark" backups that shouldn't be deleted during that process. You would run this script to prevent the disk from getting full enough to trigger the auto-delete. \$\endgroup\$ – kevinhilt Dec 17 '16 at 16:12

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.