Skip to content

Instantly share code, notes, and snippets.

@kuyagic
Created January 15, 2026 02:54
Show Gist options
  • Select an option

  • Save kuyagic/8a422b2a76c1adb99facf9ade73fef2e to your computer and use it in GitHub Desktop.

Select an option

Save kuyagic/8a422b2a76c1adb99facf9ade73fef2e to your computer and use it in GitHub Desktop.
borg-backup
#!/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"
# ~/.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