Created
January 15, 2026 02:54
-
-
Save kuyagic/8a422b2a76c1adb99facf9ade73fef2e to your computer and use it in GitHub Desktop.
borg-backup
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
| #!/bin/bash | |
| # fd-borg โ All-in-one backup script with YOUR fdfile logic (fixed for compatibility) | |
| # Usage: fd-borg [-s] [--config FILE] [--dry-run] [--comment TEXT] PATH [PATH...] | |
| set -euo pipefail | |
| SELF=$(basename "$0") | |
| # ---- ๅฎๅ จๅๅงๅ USER ๅ HOME ---- | |
| # ๆนๆณ 1๏ผไผๅ ็จ็ฏๅขๅ้๏ผๅฆๅ fallback ๅฐ $(id -un) | |
| : ${USER:=$(id -un)} | |
| : ${HOME:=$(getent passwd "$USER" | cut -d: -f6)} | |
| export HOME USER | |
| # === fdfile function (fixed: global temp dir for trap cleanup) === | |
| fdfile() { | |
| # Create temp dir (use global for trap cleanup) | |
| _FDFILE_TEMP_DIR=$(mktemp -d) || { echo "โ mktemp failed" >&2; return 1; } | |
| # Cleanup at end (guaranteed) | |
| cleanup() { | |
| [[ -n "${_FDFILE_TEMP_DIR:-}" ]] && rm -rf "$_FDFILE_TEMP_DIR" | |
| } | |
| trap cleanup EXIT | |
| # Prepare keep list (exact list + .ignore) | |
| local KEEP_IN_CACHE_DIR=( | |
| "CACHEDIR.TAG" | |
| ".ignore" | |
| ".gitignore" | |
| ".fdignore" | |
| ".rgignore" | |
| ".kopiaignore" | |
| ) | |
| printf '%s\n' "${KEEP_IN_CACHE_DIR[@]}" > "$_FDFILE_TEMP_DIR/keep_in_cache.txt" | |
| local RESULT="$_FDFILE_TEMP_DIR/files.txt" | |
| > "$RESULT" | |
| local ROOT | |
| for ROOT in "$@"; do | |
| [[ -z "$ROOT" ]] && continue | |
| ROOT="$(realpath "$ROOT" 2>/dev/null)" || { echo "โ ๏ธ Invalid path: $ROOT" >&2; continue; } | |
| [[ -d "$ROOT" ]] || { echo "โ ๏ธ Skip non-dir: $ROOT" >&2; continue; } | |
| echo "๐ Scanning $ROOT" >&2 | |
| # Step 1: fd list (respect ignores) | |
| local FD_LIST="$_FDFILE_TEMP_DIR/fd_$(printf '%s' "$ROOT" | sha1sum 2>/dev/null | cut -c1-8 || echo 'default').txt" | |
| (cd "$ROOT" && fdfind --type f --hidden --strip-cwd-prefix 2>/dev/null) > "$FD_LIST" || { | |
| echo "โ ๏ธ fd failed in $ROOT" >&2 | |
| continue | |
| } | |
| # Step 2: find cache dirs | |
| local CACHE_DIRS="$_FDFILE_TEMP_DIR/cache_dirs_$(printf '%s' "$ROOT" | sha1sum 2>/dev/null | cut -c1-8 || echo 'default').txt" | |
| find "$ROOT" -name CACHEDIR.TAG -exec dirname {} \; 2>/dev/null | sed 's|/*$|/|' > "$CACHE_DIRS" | |
| # Step 3: filter | |
| local relpath abspath basename | |
| while IFS= read -r relpath || [[ -n $relpath ]]; do | |
| [[ -z "$relpath" ]] && continue | |
| abspath="$ROOT/$relpath" | |
| basename="$(basename "$abspath")" | |
| local in_cache_dir=false | |
| local cache_dir | |
| while IFS= read -r cache_dir || [[ -n $cache_dir ]]; do | |
| [[ -z "$cache_dir" ]] && continue | |
| if [[ "$abspath/" == "$cache_dir"* ]]; then | |
| in_cache_dir=true | |
| break | |
| fi | |
| done < "$CACHE_DIRS" | |
| if $in_cache_dir; then | |
| if grep -Fxq "$basename" "$_FDFILE_TEMP_DIR/keep_in_cache.txt" 2>/dev/null; then | |
| echo "$abspath" | |
| fi | |
| else | |
| echo "$abspath" | |
| fi | |
| done < "$FD_LIST" >> "$RESULT" | |
| done | |
| sort -u "$RESULT" | |
| # โ trap cleanup EXIT will remove $_FDFILE_TEMP_DIR when function exits | |
| } | |
| # === Borg config & CLI parsing === | |
| BORG_REPO_DEFAULT="ssh://box/~/data/borg" | |
| BORG_REMOTE_PATH_DEFAULT="borg" | |
| BORG_PASSPHRASE_DEFAULT="" | |
| BORG_COMPRESSION_DEFAULT="zstd,15" | |
| DEFAULT_COMMENT_DEFAULT="" | |
| BORG_REPO="$BORG_REPO_DEFAULT" | |
| BORG_REMOTE_PATH="$BORG_REMOTE_PATH_DEFAULT" | |
| BORG_PASSPHRASE="$BORG_PASSPHRASE_DEFAULT" | |
| BORG_COMPRESSION="$BORG_COMPRESSION_DEFAULT" | |
| DEFAULT_COMMENT="$DEFAULT_COMMENT_DEFAULT" | |
| DRY_RUN=false | |
| ENABLE_STATS=false | |
| CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/fd-borg.conf" | |
| NAME_PREFIX="" | |
| # Parse global options | |
| while [[ $# -gt 0 ]] && [[ "$1" == --* || "$1" == -[!-]* ]]; do | |
| case "$1" in | |
| -s|--stats) ENABLE_STATS=true; shift ;; | |
| --config) | |
| if [[ -z "${2:-}" ]] || [[ "$2" == --* ]]; then | |
| echo "โ Error: --config requires a file path." >&2 | |
| exit 1 | |
| fi | |
| CONFIG_FILE="$2"; shift 2 ;; | |
| --config=*) CONFIG_FILE="${1#*=}"; shift ;; | |
| --dry-run|-d) DRY_RUN=true; shift ;; | |
| --help|-h) | |
| cat <<EOF | |
| Usage: $SELF [-s] [--config FILE] [--dry-run] [--comment TEXT] PATH [PATH...] | |
| Options: | |
| -s, --stats Enable borg --stats | |
| --config FILE Config file (default: ~/.config/fd-borg.conf) | |
| -d, --dry-run Preview only | |
| --comment TEXT Archive comment | |
| --name-prefix TEXT Prefix to insert in archive name (e.g., 'dev', 'conf') | |
| fdfile logic (yours): | |
| In dirs with CACHEDIR.TAG, keep ONLY: | |
| CACHEDIR.TAG, .gitignore, .fdignore, .rgignore, .kopiaignore, .ignore | |
| EOF | |
| exit 0 ;; | |
| *) break ;; | |
| esac | |
| done | |
| # === Load config file (FIXED: supports spaces in values) === | |
| if [[ -f "$CONFIG_FILE" ]]; then | |
| echo "๐ง Loading config: $CONFIG_FILE" >&2 | |
| while IFS= read -r line || [[ -n "$line" ]]; do | |
| # Remove comments and trim | |
| line="${line%%#*}" | |
| line="${line#"${line%%[![:space:]]*}"}" # ltrim | |
| line="${line%"${line##*[![:space:]]}"}" # rtrim | |
| [[ -z "$line" ]] && continue | |
| # Parse key=value with regex (handles spaces in value) | |
| if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then | |
| key="${BASH_REMATCH[1]}" | |
| val="${BASH_REMATCH[2]}" | |
| # Strip surrounding single/double quotes | |
| if [[ "$val" =~ ^\"(.*)\"$ ]]; then | |
| val="${BASH_REMATCH[1]}" | |
| elif [[ "$val" =~ ^\'(.*)\'$ ]]; then | |
| val="${BASH_REMATCH[1]}" | |
| fi | |
| case "$key" in | |
| BORG_REPO) BORG_REPO="$val" ;; | |
| BORG_REMOTE_PATH) BORG_REMOTE_PATH="$val" ;; | |
| BORG_PASSPHRASE) BORG_PASSPHRASE="$val" ;; | |
| BORG_COMPRESSION) BORG_COMPRESSION="$val" ;; | |
| DEFAULT_COMMENT) DEFAULT_COMMENT="$val" ;; | |
| *) echo "โ ๏ธ Unknown key '$key' in config" >&2 ;; | |
| esac | |
| else | |
| echo "โ ๏ธ Invalid line in config: $line" >&2 | |
| fi | |
| done < "$CONFIG_FILE" | |
| elif [[ "$*" == *"--config"* ]] || [[ "$0" == *" --config "* ]] || [[ "$0" == *"--config="* ]]; then | |
| echo "โ Config file not found: $CONFIG_FILE" >&2 | |
| exit 1 | |
| fi | |
| # Parse remaining args | |
| COMMENT="$DEFAULT_COMMENT" | |
| declare -a PATHS=() | |
| TEMP_ARGS=("$@"); set -- "${TEMP_ARGS[@]}" | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -s|--stats|--config|--config=*|--dry-run|-d) shift 2 2>/dev/null || shift ;; | |
| --comment) COMMENT="${2?}"; shift 2 ;; | |
| --comment=*) COMMENT="${1#*=}"; shift ;; | |
| --name-prefix|-p) NAME_PREFIX="${2?}"; shift 2 ;; | |
| --name-prefix=*) NAME_PREFIX="${1#*=}"; shift ;; | |
| --help|-h) "$0" --help; exit 0 ;; | |
| --*) echo "โ Unknown option: $1" >&2; "$0" --help >&2; exit 1 ;; | |
| *) PATHS+=("$1"); shift ;; | |
| esac | |
| done | |
| # Expand paths, skip missing | |
| shopt -s nullglob 2>/dev/null || true | |
| expanded_paths=() | |
| for path in "${PATHS[@]}"; do | |
| if [[ "$path" == *\** ]] && compgen -G "$path" >/dev/null 2>&1; then | |
| matches=($path) | |
| (( ${#matches[@]} == 0 )) && echo "โ ๏ธ No match for glob: $path" >&2 | |
| for m in "${matches[@]}"; do [[ -e "$m" ]] && expanded_paths+=("$m"); done | |
| else | |
| [[ ! -e "$path" ]] && echo "โ ๏ธ Skipping non-existent: $path" >&2 || expanded_paths+=("$path") | |
| fi | |
| done | |
| shopt -u nullglob 2>/dev/null || true | |
| if [[ ${#expanded_paths[@]} -eq 0 ]]; then | |
| echo "โน๏ธ No valid paths โ skipping backup." >&2 | |
| exit 0 | |
| fi | |
| # Generate ULID | |
| ULID=$(ulid 2>/dev/null || uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' | cut -c1-16) || { | |
| echo "โ 'ulid' or 'uuidgen' required" >&2 | |
| exit 1 | |
| } | |
| ARCHIVE_NAME="${HOSTNAME:-$(hostname)}${NAME_PREFIX:+_${NAME_PREFIX}}_${ULID}" | |
| # Dry-run mode | |
| if [[ "$DRY_RUN" == true ]]; then | |
| echo "๐ DRY RUN โ Config: $CONFIG_FILE" | |
| echo " Archive: $BORG_REPO::$ARCHIVE_NAME" | |
| echo " Stats: $ENABLE_STATS" | |
| echo " Valid paths (${#expanded_paths[@]}):" | |
| printf " %s\n" "${expanded_paths[@]}" | |
| echo "" | |
| echo "๐ Files to be backed up (respecting ignore rules):" | |
| echo "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ" | |
| # ่ฐ็จ fdfile ่ทๅๆไปถๅ่กจ | |
| file_list=$(fdfile "${expanded_paths[@]}" 2>/dev/null) | |
| if [[ -z "$file_list" ]]; then | |
| echo "โ ๏ธ No files to backup" | |
| echo "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ" | |
| exit 0 | |
| fi | |
| # ่พ ๅฉๅฝๆฐ๏ผๅญ่่ฝฌๅฏ่ฏปๆ ผๅผ | |
| format_size() { | |
| local bytes=$1 | |
| if (( bytes >= 1073741824 )); then | |
| printf "%.1fG" "$(echo "scale=1; $bytes / 1073741824" | bc)" | |
| elif (( bytes >= 1048576 )); then | |
| printf "%.1fM" "$(echo "scale=1; $bytes / 1048576" | bc)" | |
| elif (( bytes >= 1024 )); then | |
| printf "%.1fK" "$(echo "scale=1; $bytes / 1024" | bc)" | |
| else | |
| printf "%dB" "$bytes" | |
| fi | |
| } | |
| # ่ฏปๅๆฏไธ่กๅนถ่พๅบ | |
| total_size=0 | |
| file_count=0 | |
| while IFS= read -r file || [[ -n "$file" ]]; do | |
| [[ -z "$file" ]] && continue | |
| if [[ -f "$file" ]]; then | |
| file_count=$((file_count + 1)) | |
| size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo 0) | |
| total_size=$((total_size + size)) | |
| # ๅไธชๆไปถ็ๅฏ่ฏปๅคงๅฐ | |
| size_h=$(format_size "$size") | |
| # ๆไปถๅคงๅฐๆพๅจ็ฌฌไธๅ๏ผๅฎฝๅบฆ20ไธชๅญ็ฌฆ๏ผๅณๅฏน้ฝ | |
| printf " %20s %s\n" "$size_h" "$file" | |
| fi | |
| done <<< "$file_list" | |
| # ๆ ผๅผๅๆปๅคงๅฐ | |
| total_size_h=$(format_size "$total_size") | |
| echo "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ" | |
| printf " %20s Total files: %d\n" "$total_size_h" "$file_count" | |
| echo " Compression: $BORG_COMPRESSION" | |
| echo " Comment: ${COMMENT:-<none>}" | |
| exit 0 | |
| fi | |
| # Real backup | |
| echo "๐ฆ Backing up ${#expanded_paths[@]} path(s) to: $ARCHIVE_NAME" | |
| $ENABLE_STATS && echo " Stats: enabled" >&2 | |
| export BORG_PASSPHRASE | |
| export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes | |
| { | |
| file_list=$(fdfile "${expanded_paths[@]}") | |
| if [[ -z "$file_list" ]]; then | |
| echo "โ Error: fdfile produced no files (fdfind missing? permission denied?)" >&2 | |
| exit 1 | |
| fi | |
| echo "$file_list" | |
| } | borg create \ | |
| --remote-path "$BORG_REMOTE_PATH" \ | |
| --comment "$COMMENT" \ | |
| --compression "$BORG_COMPRESSION" \ | |
| --filter AM \ | |
| --list \ | |
| ${ENABLE_STATS:+--stats} \ | |
| --paths-from-stdin \ | |
| "$BORG_REPO::$ARCHIVE_NAME" | |
| echo "โ Success: $BORG_REPO::$ARCHIVE_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
| # ~/.config/fd-borg.conf | |
| BORG_REPO=192.168.11.12:/data/borgbackup.repo | |
| BORG_PASSPHRASE=<your-borg-repo-password> | |
| BORG_COMPRESSION=zstd,15 | |
| DEFAULT_COMMENT="LAN backup via fd-borg" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment