Skip to content

Instantly share code, notes, and snippets.

@beporter
Last active March 28, 2025 22:40
Show Gist options
  • Save beporter/8645c385b99ffc87ab16e88f13c52969 to your computer and use it in GitHub Desktop.
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.
#!/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"
#!/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