#!/bin/bash set -euo pipefail # --- Functions --- print_usage() { echo "Usage: $0 [backup_output_path] [--list file_with_paths] [--exclude path] [--exclude-from file] -- ..." echo echo "Examples:" echo " $0 /backup/my_backup.tar.gz -- /home/user /etc" echo " BACKUP_PATH=/backup/auto.tar.gz $0 --list dirs.txt -- /var/log" echo " $0 /mnt/backup/nextcloud_mirror -- /srv/nextcloud" echo " $0 /backup.tgz --exclude \"/home/*/.cache\" -- /home" exit 1 } parse_args() { local -n _target_path=$1 local -n _src_dirs=$2 local -n _list_file=$3 local -n _archive_type=$4 local -n _exclude_paths=$5 local -n _exclude_file=$6 local positional=() local in_sources=0 shift 6 while [[ $# -gt 0 ]]; do case "$1" in --list) shift _list_file="${1:-}" if [[ -z "$_list_file" || ! -f "$_list_file" ]]; then echo "Error: list file '$_list_file' not found." >&2 print_usage fi shift ;; --exclude) shift _exclude_paths+=("$1") shift ;; --exclude-from) shift _exclude_file="$1" if [[ ! -f "$_exclude_file" ]]; then echo "Error: exclude-from file '$_exclude_file' not found." >&2 print_usage fi shift ;; --) in_sources=1 shift ;; *) if [[ $in_sources -eq 1 ]]; then _src_dirs+=("$1") else positional+=("$1") fi shift ;; esac done if [[ ${#positional[@]} -gt 1 ]]; then echo "Error: Too many positional arguments before --" >&2 print_usage elif [[ ${#positional[@]} -eq 1 ]]; then _target_path="${positional[0]}" else _target_path="${BACKUP_PATH:-}" fi if [[ -z "$_target_path" ]]; then echo "Error: No target path provided and BACKUP_PATH is not set." >&2 print_usage fi case "$_target_path" in *.tar) _archive_type="tar";; *.tar.gz|*.tgz) _archive_type="targz";; *.tar.xz|*.txz) _archive_type="tarxz";; *.tar.bz2|*.tbz) _archive_type="tarbz2";; *.7z) _archive_type="7z";; *) _archive_type="NONE";; esac } load_dirs_from_list_file() { local -n _src_dirs=$1 local list_file="$2" if [[ -n "$list_file" ]]; then while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "$line" || "$line" =~ ^# ]] && continue _src_dirs+=("$line") done < "$list_file" fi } validate_src_dirs() { local -n _src_dirs=$1 local valid_dirs=() for dir in "${_src_dirs[@]}"; do if [[ -d "$dir" ]]; then valid_dirs+=("$dir") else echo "Warning: '$dir' is not a valid directory. Skipping." >&2 fi done _src_dirs=("${valid_dirs[@]}") if [[ ${#_src_dirs[@]} -eq 0 ]]; then echo "Error: No valid source directories to back up." >&2 print_usage fi } init_tmp_dir() { mktemp -d -t backup-tmp-XXXXXX } count_files() { local exclude_args=() local src_dirs=() while [[ "$1" != "--" ]]; do exclude_args+=("$1") shift done shift # skip the "--" src_dirs=("$@") local count=0 for dir in "${src_dirs[@]}"; do local find_cmd=(find "$dir" -xdev) if [[ ${#exclude_args[@]} -gt 0 ]]; then find_cmd+=(\( "${exclude_args[@]}" -prune \) -o -print) fi count=$((count + $("${find_cmd[@]}" | wc -l))) done echo "$count" } run_rsync_backup() { local src_dirs=(${!1}) local dest_path="$2" local file_count="$3" local -n exclude_paths=$4 local exclude_file="$5" echo "Starting backup..." local rsync_opts=("-Aavx") for e in "${exclude_paths[@]}"; do rsync_opts+=("--exclude=$e") done if [[ -n "$exclude_file" ]]; then rsync_opts+=("--exclude-from=$exclude_file") fi for dir in "${src_dirs[@]}"; do base="$(basename "$dir")" dest_dir="$dest_path/$base" rsync "${rsync_opts[@]}" "$dir"/ "$dest_dir"/ | pv -ltps "$file_count" > /dev/null done echo "Rsync completed." } compress_backup() { local tmp_dir="$1" local target_path="$2" local archive_type="$3" echo "Creating archive: $target_path" case "$archive_type" in targz) tar czf - -C "$tmp_dir" . | pv -s "$(du -sb "$tmp_dir" | awk '{print $1}')" > "$target_path" ;; tar) tar cf - -C "$tmp_dir" . | pv -s "$(du -sb "$tmp_dir" | awk '{print $1}')" > "$target_path" ;; tarxz) tar cJf - -C "$tmp_dir" . | pv -s "$(du -sb "$tmp_dir" | awk '{print $1}')" > "$target_path" ;; tarbz2) tar cjf - -C "$tmp_dir" . | pv -s "$(du -sb "$tmp_dir" | awk '{print $1}')" > "$target_path" ;; 7z) 7z a -mx=9 "$target_path" "$tmp_dir"/* ;; *) echo "Unsupported archive type or no compression." >&2 print_usage ;; esac echo "Archive created successfully." } cleanup_tmp_dir() { local tmp_dir="$1" if [[ -n "$tmp_dir" && -d "$tmp_dir" ]]; then echo "Cleaning up temporary files..." rm -rf "$tmp_dir" echo "Done." fi } teardown() { echo "Do sync..." sync } main() { local TARGET_PATH="" local SRC_DIRS=() local LIST_FILE="" local ARCHIVE_TYPE="NONE" local EXCLUDE_PATHS=() local EXCLUDE_FILE="" parse_args TARGET_PATH SRC_DIRS LIST_FILE ARCHIVE_TYPE EXCLUDE_PATHS EXCLUDE_FILE "$@" load_dirs_from_list_file SRC_DIRS "$LIST_FILE" validate_src_dirs SRC_DIRS local exclude_args=() for e in "${EXCLUDE_PATHS[@]}"; do exclude_args+=( -path "$e" -o ) done if [[ -n "$EXCLUDE_FILE" ]]; then while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "$line" || "$line" =~ ^# ]] && continue exclude_args+=( -path "$line" -o ) done < "$EXCLUDE_FILE" fi [[ ${#exclude_args[@]} -gt 0 ]] && unset 'exclude_args[${#exclude_args[@]}-1]' local FILE_COUNT FILE_COUNT=$(count_files "${exclude_args[@]}" -- "${SRC_DIRS[@]}") if [[ "$ARCHIVE_TYPE" != "NONE" ]]; then local TMP_DIR TMP_DIR=$(init_tmp_dir) run_rsync_backup SRC_DIRS[@] "$TMP_DIR" "$FILE_COUNT" EXCLUDE_PATHS "$EXCLUDE_FILE" compress_backup "$TMP_DIR" "$TARGET_PATH" "$ARCHIVE_TYPE" cleanup_tmp_dir "$TMP_DIR" else mkdir -p "$TARGET_PATH" run_rsync_backup SRC_DIRS[@] "$TARGET_PATH" "$FILE_COUNT" EXCLUDE_PATHS "$EXCLUDE_FILE" fi teardown } # --- Entry Point --- main "$@"