Last active
March 28, 2025 22:40
-
-
Save beporter/8645c385b99ffc87ab16e88f13c52969 to your computer and use it in GitHub Desktop.
Having reimplemented this logic probably a dozen times, I'm finally throwing in all the bells and whistles and committing this to a gist for future reference. More notes in the script itself.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# When `source`d, this script will define a `rotate_backups` function. | |
# When executed, the script will look for required $1 and $2 arguments | |
# and execute the function using those. | |
# Author: beporter at users dot sourceforge dot net | |
set -eo pipefail | |
shopt -s extglob | |
# Moves "${2}.1" to "${2}.2", then "$2" to "${2}.1", etc., making room | |
# for a new $2 file to be created. It is up to the caller to ensure | |
# both the directory and the target file exist. | |
# Ref: https://unix.stackexchange.com/a/603456/32976 | |
# | |
# $1 = Path to examine, no trailing slash. | |
# $2 = Base filename, including the file's normal extension. | |
function rotate_backups { | |
echo "Rotating $1/$2" | |
# "Version" sort order with `-V` is critical. | |
local ALL_VERSIONS=($(find -E "$1" -maxdepth 1 -type f -regex '.*/?'"$2"'(\.[0-9]+)?' | sort -uV)) | |
# If there are any gaps in the numbering, we only need to process | |
# from the original to the first gap. So | |
# | |
# `orig orig.1 orig.2 orig.5 orig.6` | |
# | |
# becomes | |
# | |
# ` orig.1 orig.2 orig.3 orig.5 orig.6` | |
# | |
local FIRST_CONTIGUOUS=() | |
for (( i=0; i < ${#ALL_VERSIONS[@]}; i++ )); do | |
FULL_PATH="${ALL_VERSIONS[i]}" | |
PATH_WO_BAK="${FULL_PATH%%.+([0-9])}" | |
EXT=${FULL_PATH//$PATH_WO_BAK?(.)/} | |
if [ -z "$EXT" ]; then | |
EXT=0 | |
fi | |
if [[ $i -ne $EXT ]]; then | |
break | |
fi | |
FIRST_CONTIGUOUS[${#FIRST_CONTIGUOUS[@]}]=${ALL_VERSIONS[i]} | |
done | |
# Reverse the first contiguous set of files so we start with the | |
# largest suffix value and work down towards the original file. | |
local REVERSED=() | |
for i in "${FIRST_CONTIGUOUS[@]}"; do | |
REVERSED=("$i" "${REVERSED[@]}") | |
done | |
# Move each file to its next increment, starting with the largest. | |
for FULL_PATH in "${REVERSED[@]}"; do | |
PATH_WO_BAK="${FULL_PATH%%.+([0-9])}" | |
I=${FULL_PATH//$PATH_WO_BAK?(.)/} | |
# If we're looking at `original`, there is no previous to examine. | |
# Fudge the variables a bit to get the expected output. | |
if [ -z "$I" ]; then | |
PREV_EXT="" # Use the original to count as 'existing'. | |
CURR_EXT="" # Target the original for a new backup extension. | |
I=0 # Make sure arithmetic works properly later. | |
# If we're looking at `original.1`, | |
# previous is the `original` and has no extension. | |
elif [ "$I" -eq "1" ]; then | |
PREV_EXT="" | |
CURR_EXT=".1" | |
# Otherwise previous is `original.$((I - 1))`. | |
else | |
PREV_EXT=".$(( I - 1 ))" | |
CURR_EXT=".${I}" | |
fi | |
# Next extension is always a dot and an incremented number. | |
NEXT_EXT=".$(( I + 1 ))" | |
# Make the actual move. | |
mv -v "${PATH_WO_BAK}${CURR_EXT}" "${PATH_WO_BAK}${NEXT_EXT}" | |
done | |
# Recreate the original, if desired. | |
# touch "${PATH_WO_BAK}" | |
} | |
# If we're being `source`d, return here. | |
[[ "${BASH_SOURCE[0]}" != "${0}" ]] && return | |
# Otherwise act like an executable: enforce args and call the function. | |
PATH_TO_EXAMINE=${1?'Must provide a directory path to examine as the first arg.'} | |
BASE_FILE_NAME=${2?'Must provide a base file name for which to rotate backup copies as the second arg.'} | |
rotate_backups "$PATH_TO_EXAMINE" "$BASE_FILE_NAME" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# Create some test files and run the function against them repeatedly. | |
# Place in the same folder as `rotate_backups.sh`, `chmod +x tests.sh`, `./tests.sh` | |
# Compare the BEFORE and AFTER outputs. | |
set -eo pipefail | |
# First confirm we can execute the script as a script. | |
./rotate_backups.sh "/tmp" "no_file.txt" | |
echo "If you're reading this, we called the rotate_backups.sh as an executable successfully." | |
# Then pull the function in for re-use. | |
source "./rotate_backups.sh" | |
# Set up some testing data. | |
TMP_DIR="$(mktemp -d)" | |
FILE_NAMES=("test.jpg" ".zprofile" ".dotted_parent/config" ".4dotted_numeral") | |
FILE_VERSIONS=(1 2 4 5) | |
for FILE_NAME in "${FILE_NAMES[@]}"; do | |
# Create original. | |
mkdir -p -- $(dirname "${TMP_DIR}/${FILE_NAME}") || true # Ignore dirs already present. | |
touch "${TMP_DIR}/${FILE_NAME}" | |
# Create backup versions. | |
for V in "${FILE_VERSIONS[@]}"; do | |
mkdir -p -- $(dirname "${TMP_DIR}/${FILE_NAME}") || true | |
touch -- "${TMP_DIR}/${FILE_NAME}.${V}" | |
done | |
# Convenience var. | |
FULL_PATH="${TMP_DIR}/${FILE_NAME}" | |
# Execute on the base file. | |
echo "" | |
echo ">> BEFORE >>" | |
ls -la $(dirname "${FULL_PATH}") | grep $(basename "${FULL_PATH}") | |
# Compare the results. | |
rotate_backups "$(dirname "${FULL_PATH}")" "$(basename "${FULL_PATH}")" | |
echo "<< AFTER FIRST ROTATE <<" | |
ls -la $(dirname "${FULL_PATH}") | grep $(basename "${FULL_PATH}") | |
# Recreate the original and rotate again. | |
touch "${FULL_PATH}" | |
rotate_backups "$(dirname "${FULL_PATH}")" "$(basename "${FULL_PATH}")" | |
echo "<< AFTER SECOND ROTATE <<" | |
ls -la $(dirname "${FULL_PATH}") | grep $(basename "${FULL_PATH}") | |
done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment