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
-
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– 200_success2016年12月17日 06:13:11 +00:00Commented Dec 17, 2016 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– Mast ♦2016年12月17日 12:55:08 +00:00Commented Dec 17, 2016 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– kevinhilt2016年12月17日 16:12:42 +00:00Commented Dec 17, 2016 at 16:12
1 Answer 1
$(basename ${BASH_SOURCE[0]})
This seems fine to me, but I will note in passing that if there were embedded SPACE characters they aren't quoted. And on my MacOS Monterey machine it triggers a "usage:" warning since BASH_SOURCE is the empty array.
amount_of_backups_to_delete="1ドル"
Good.
Now validate that value, to ensure that we did not for example
store "--newest-to-oldest"
due to an erroneous invocation.
Move that subsequent -z
check up here,
and you may as well insist on -gt 0
numeric or "all"
.
delete_newest_to_oldest=true
...
delete_newest_to_oldest=false
It seems more natural to init to false
at the top,
and overwrite with true
during arg parsing.
The -z
tests aren't really compatible with set -u
.
Personally, I like to set that, plus set -e -o pipefail
,
in most scripts I write.
For example if tmutil
reports there's no backups to list,
it might be helpful to bail at once.
index=`expr ${#all_backup_paths[@]} - 1 - $index`
You might rephrase the backticks as $(expr ... )
,
or ask bash to do builtin arithmetic with $(( ... ))
.
if [[ $name == $backup_name ]]
Add quoting, please, in case we encounter embedded whitespace.
In general, run shellcheck
and follow its advice.
There are several minor issues with globbing in this script.
sudo time /usr/bin/tmutil delete ${to_delete_backup_paths[$i]}
I don't understand that line.
Didn't we already insist that the user invoked us as UID 0
?
This appears to be leftover,
from before that check was added,
so just delete the sudo
call.
echo "... so nothing was deleted."
Nice UX, which leaves nothing to the user's fevered imagination. Very clear.
This script takes a sensibly conservative approach to its sysad task. I would be willing to delegate or accept maintenance tasks on it.
-
\$\begingroup\$
[[
has strange rules about word-splitting and quotes. If we'd used[
, the quotes would indeed be needed there (and more of the code would be portably re-usable). \$\endgroup\$Toby Speight– Toby Speight2023年06月13日 04:32:21 +00:00Commented Jun 13, 2023 at 4:32