The scripts were attributed to LINUXexpert.org, which is being retired as a site and is no longer where this work lives. Coffey Labs is the organisation these projects belong to. One line per script, fifteen of them, and nothing else. LICENSE is deliberately untouched: its "Copyright (C) <year> <name of author>" lines are GPL boilerplate showing you how to write your own notice, and the Free Software Foundation's own copyright on the licence text is not ours to edit. Both git contributors are the same person, so there is no third-party copyright here that could not be restated.
139 lines
4.7 KiB
Bash
139 lines
4.7 KiB
Bash
#!/bin/bash
|
|
|
|
# rsync_magic.sh - A smart, feature-rich rsync wrapper for safe and powerful file synchronization.
|
|
#
|
|
# Copyright (C) 2025 Coffey Labs
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU General Public License as published by the
|
|
# Free Software Foundation, version 3 of the License.
|
|
#
|
|
# This program is distributed in the hope that it will be useful, but
|
|
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
# for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License along
|
|
# with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
set -euo pipefail
|
|
|
|
# ======= Configuration =======
|
|
LOG_FILE="/var/log/rsync_magic.log"
|
|
EXCLUDES="/etc/rsync_magic_excludes.txt"
|
|
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
|
|
|
# ======= Help Function =======
|
|
usage() {
|
|
echo "Usage: $0 [--dry-run] [--inplace] [--yes] <source> <destination>"
|
|
echo " --dry-run simulate the sync, change nothing"
|
|
echo " --inplace write into destination files directly (see note below)"
|
|
echo " --yes skip the confirmation prompt for --delete"
|
|
exit 1
|
|
}
|
|
|
|
# ======= Argument Parsing =======
|
|
DRY_RUN=0
|
|
INPLACE=0
|
|
ASSUME_YES=0
|
|
|
|
while [[ "${1:-}" == --* ]]; do
|
|
case "$1" in
|
|
--dry-run) DRY_RUN=1 ;;
|
|
--inplace) INPLACE=1 ;;
|
|
--yes) ASSUME_YES=1 ;;
|
|
--help|-h) usage ;;
|
|
*) echo "Unknown option: $1" >&2; usage ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
SOURCE="${1:-}"
|
|
DEST="${2:-}"
|
|
|
|
if [[ -z "$SOURCE" || -z "$DEST" ]]; then
|
|
usage
|
|
fi
|
|
|
|
if [[ ! -d "$SOURCE" ]]; then
|
|
echo "Error: Source directory '$SOURCE' not found!"
|
|
exit 2
|
|
fi
|
|
|
|
mkdir -p "$DEST"
|
|
|
|
# ======= Construct Rsync Command =======
|
|
RSYNC_OPTS=(
|
|
-a # archive mode (recursive, preserve symlinks, perms, times, groups, etc.)
|
|
-v # verbose
|
|
-z # compress during transfer
|
|
-h # human-readable output
|
|
-u # skip files that are newer on the receiver
|
|
-P # show progress during transfer and allow partial transfer resume
|
|
-c # compare files using checksum
|
|
-x # don't cross filesystem boundaries
|
|
-A # preserve ACLs
|
|
-X # preserve extended attributes
|
|
--delete # delete extraneous files from destination
|
|
--numeric-ids # don't map uid/gid numbers to usernames
|
|
--backup # backup overwritten files
|
|
--backup-dir="${DEST}/.backup-${TIMESTAMP}" # backup location
|
|
)
|
|
|
|
# --inplace was previously always on. It writes directly into the
|
|
# destination file rather than to a temporary and renaming, so an
|
|
# interrupted transfer leaves the destination partially overwritten and
|
|
# corrupt -- the opposite of what a backup should guarantee. rsync's
|
|
# default (temp file, atomic rename) costs extra space on the target and
|
|
# is worth it here, so --inplace is now opt-in for the cases that need
|
|
# it, such as very large files on space-constrained targets.
|
|
if [[ "$INPLACE" -eq 1 ]]; then
|
|
RSYNC_OPTS+=(--inplace)
|
|
echo "WARNING: --inplace means an interrupted run can leave corrupt files at the destination."
|
|
fi
|
|
|
|
# Add dry-run flag if needed
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
RSYNC_OPTS+=(--dry-run)
|
|
echo "Running in DRY RUN mode..."
|
|
fi
|
|
|
|
# Add exclude file if it exists
|
|
if [[ -f "$EXCLUDES" ]]; then
|
|
RSYNC_OPTS+=(--exclude-from="$EXCLUDES")
|
|
fi
|
|
|
|
# The log lives under /var/log, which needs root. Every echo below pipes
|
|
# through `tee -a`, so with set -o pipefail an unprivileged run died on
|
|
# the first line with a bare "Permission denied" and no explanation.
|
|
# Fall back to stdout instead of failing the sync over its logging.
|
|
if ! { [ -w "$LOG_FILE" ] || { [ ! -e "$LOG_FILE" ] && [ -w "$(dirname "$LOG_FILE")" ]; }; }; then
|
|
echo "NOTE: cannot write $LOG_FILE (need root); logging to stdout only." >&2
|
|
LOG_FILE=/dev/null
|
|
fi
|
|
|
|
# --delete removes anything at the destination that is not in the source.
|
|
# Reversing the two arguments therefore erases the backup. --backup-dir
|
|
# above catches the deleted files, but confirm anyway -- the prompt is
|
|
# cheaper than discovering the mistake later.
|
|
if [[ "$DRY_RUN" -ne 1 && "$ASSUME_YES" -ne 1 ]]; then
|
|
echo "About to sync with --delete:"
|
|
echo " FROM: $SOURCE/"
|
|
echo " TO: $DEST/ (extraneous files here will be removed)"
|
|
if [ ! -t 0 ]; then
|
|
echo "Refusing to run unattended without --yes." >&2
|
|
exit 1
|
|
fi
|
|
read -r -p "Proceed? (yes/NO): " reply
|
|
[ "$reply" = "yes" ] || { echo "Cancelled."; exit 0; }
|
|
fi
|
|
|
|
# ======= Run Rsync =======
|
|
echo "Starting rsync at $TIMESTAMP" | tee -a "$LOG_FILE"
|
|
echo "Source: $SOURCE" | tee -a "$LOG_FILE"
|
|
echo "Destination: $DEST" | tee -a "$LOG_FILE"
|
|
|
|
rsync "${RSYNC_OPTS[@]}" "$SOURCE/" "$DEST/" 2>&1 | tee -a "$LOG_FILE"
|
|
|
|
echo "rsync completed at $(date +"%Y-%m-%d_%H-%M-%S")" | tee -a "$LOG_FILE"
|