From 39f4066d85f2d69986f1ab64d9df6322e284154e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 16 Mar 2026 18:33:06 +0800 Subject: [PATCH] Improve exclude rule handling and docs --- backup.sh | 164 +++++++++++++++++++++++++++++++++++++- exclude.list | 2 + exclude.user.list.example | 13 +-- lib/telegram.sh | 5 ++ readme.md | 19 ++--- 5 files changed, 182 insertions(+), 21 deletions(-) diff --git a/backup.sh b/backup.sh index 8a9239b..ebcb206 100755 --- a/backup.sh +++ b/backup.sh @@ -26,6 +26,13 @@ log() { # shellcheck disable=SC1091 source "${SCRIPT_DIR}/lib/telegram.sh" +RUNTIME_EXCLUDE_FILE="" + +cleanup_tmp() { + [[ -n "${RUNTIME_EXCLUDE_FILE:-}" ]] && rm -f "$RUNTIME_EXCLUDE_FILE" +} +trap cleanup_tmp EXIT + on_error() { local exit_code=$? local line_no=${BASH_LINENO[0]:-} @@ -209,6 +216,156 @@ build_tar_file_list() { printf '%s\n' "${args[@]}" } +archive_path_for_abs() { + # 将真实文件系统绝对路径映射为 tar 归档内路径。 + local abs_input="$1" + local root abs_root root_base rel + + for root in "${BACKUP_PATHS[@]}"; do + abs_root="$(readlink -f "$root")" + root_base="$(basename "$abs_root")" + + if [[ "$abs_input" == "$abs_root" ]]; then + printf '%s\n' "$root_base" + return 0 + fi + + if [[ "$abs_input" == "$abs_root"/* ]]; then + rel="${abs_input#"$abs_root"/}" + printf '%s/%s\n' "$root_base" "$rel" + return 0 + fi + done + + return 1 +} + +append_exclude_path() { + local archive_path="$1" + local path_type="$2" + + printf '%s\n' "$archive_path" >> "$RUNTIME_EXCLUDE_FILE" + if [[ "$path_type" == "dir" ]]; then + printf '%s/*\n' "$archive_path" >> "$RUNTIME_EXCLUDE_FILE" + fi +} + +match_user_rule() { + # 用户规则支持两类写法: + # 1. 绝对路径,如 /root/data/dify + # 2. 通配规则,如 */.halo + # 解析后统一转换成 tar 能识别的实际排除项。 + local rule="$1" + local matched=0 + local root abs_root root_base entry archive_path path_type + local rule_tail find_name has_glob + + if [[ "$rule" == /* ]]; then + if [[ ! -e "$rule" ]]; then + log "WARN" "用户排除规则未命中任何现有路径:$rule" + return 0 + fi + + if ! archive_path="$(archive_path_for_abs "$(readlink -f "$rule")")"; then + log "WARN" "用户排除规则不在任何备份根目录内:$rule" + return 0 + fi + + if [[ -d "$rule" ]]; then + path_type="dir" + else + path_type="file" + fi + append_exclude_path "$archive_path" "$path_type" + return 0 + fi + + has_glob=false + if [[ "$rule" == *'*'* || "$rule" == *'?'* || "$rule" == *'['* ]]; then + has_glob=true + fi + + for root in "${BACKUP_PATHS[@]}"; do + abs_root="$(readlink -f "$root")" + root_base="$(basename "$abs_root")" + + if ! $has_glob; then + entry="" + if [[ "$rule" == "$root_base" ]]; then + entry="$abs_root" + elif [[ "$rule" == "$root_base"/* ]]; then + entry="${abs_root}/${rule#"$root_base"/}" + fi + + if [[ -n "$entry" && -e "$entry" ]]; then + matched=1 + if [[ -d "$entry" ]]; then + path_type="dir" + else + path_type="file" + fi + append_exclude_path "$rule" "$path_type" + fi + continue + fi + + rule_tail="${rule##*/}" + if [[ "$rule_tail" == *'*'* || "$rule_tail" == *'?'* || "$rule_tail" == *'['* ]]; then + while IFS= read -r -d '' entry; do + archive_path="$(archive_path_for_abs "$entry" || true)" + [[ -n "$archive_path" ]] || continue + if [[ "$archive_path" == $rule ]]; then + matched=1 + if [[ -d "$entry" ]]; then + path_type="dir" + else + path_type="file" + fi + append_exclude_path "$archive_path" "$path_type" + fi + done < <(find "$abs_root" -mindepth 1 -print0 2>/dev/null) + else + find_name="$rule_tail" + while IFS= read -r -d '' entry; do + archive_path="$(archive_path_for_abs "$entry" || true)" + [[ -n "$archive_path" ]] || continue + if [[ "$archive_path" == $rule ]]; then + matched=1 + if [[ -d "$entry" ]]; then + path_type="dir" + else + path_type="file" + fi + append_exclude_path "$archive_path" "$path_type" + fi + done < <(find "$abs_root" -mindepth 1 -name "$find_name" -print0 2>/dev/null) + fi + done + + if (( matched == 0 )); then + log "WARN" "用户排除规则未命中任何路径:$rule" + fi +} + +build_runtime_exclude_file() { + # 默认规则直接交给 tar。 + # 用户规则先解析成真实命中路径,再换算为归档内排除项。 + local rule + RUNTIME_EXCLUDE_FILE="$(mktemp)" + cat "$DEFAULT_EXCLUDE_FILE" > "$RUNTIME_EXCLUDE_FILE" + + [[ -f "$USER_EXCLUDE_FILE" ]] || return 0 + + while IFS= read -r rule; do + [[ -n "$rule" ]] || continue + [[ "$rule" =~ ^[[:space:]]*# ]] && continue + match_user_rule "$rule" + done < "$USER_EXCLUDE_FILE" + + awk '!seen[$0]++' "$RUNTIME_EXCLUDE_FILE" > "${RUNTIME_EXCLUDE_FILE}.dedup" + mv "${RUNTIME_EXCLUDE_FILE}.dedup" "$RUNTIME_EXCLUDE_FILE" +} + check_space() { local total=0 local p sz avail need @@ -247,15 +404,14 @@ pack() { fi log "INFO" "开始打包(支持默认与用户排除规则),pigz=${USE_PIGZ}" + build_runtime_exclude_file if $USE_PIGZ; then tar --warning=no-file-changed \ - --exclude-from="$DEFAULT_EXCLUDE_FILE" \ - --exclude-from="$USER_EXCLUDE_FILE" \ + --exclude-from="$RUNTIME_EXCLUDE_FILE" \ -c "${tar_args[@]}" | pigz > "$ARCHIVE_PATH" else tar --warning=no-file-changed \ - --exclude-from="$DEFAULT_EXCLUDE_FILE" \ - --exclude-from="$USER_EXCLUDE_FILE" \ + --exclude-from="$RUNTIME_EXCLUDE_FILE" \ -czf "$ARCHIVE_PATH" "${tar_args[@]}" fi end=$(date +%s) diff --git a/exclude.list b/exclude.list index 104c546..6c0e331 100644 --- a/exclude.list +++ b/exclude.list @@ -8,6 +8,8 @@ # 4. 排除目录时,建议同时写: # */dir # */dir/* +# 5. 上面“写两行”的建议只适用于本文件。 +# exclude.user.list 是程序先解析的高级规则,不需要用户自己再补一行 /*。 # ============================================================ # ============================================================ diff --git a/exclude.user.list.example b/exclude.user.list.example index 815f640..a9b0d7a 100644 --- a/exclude.user.list.example +++ b/exclude.user.list.example @@ -12,20 +12,21 @@ # # 规则示例: # - 排除单个文件 -# */project/src/main.js +# /root/myapp/config.json # # - 排除一个目录 -# */project/cache -# */project/cache/* +# /root/data/dify +# */.halo # -# - 排除某个固定路径 +# - 排除某个固定路径(推荐直接写绝对路径) # /root/data/dify # # - 排除任意位置的隐藏目录 # */.halo -# */.halo/* +# 说明: +# 程序会在实际备份目录中匹配这条规则,并自动判断命中的是文件还是目录。 +# 如果规则当前没有命中任何实际路径,脚本会记录 WARN,但不会直接退出失败。 # ============================================================ /root/data/dify */.halo -*/.halo/* diff --git a/lib/telegram.sh b/lib/telegram.sh index 723aa72..48a4e78 100755 --- a/lib/telegram.sh +++ b/lib/telegram.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash +# 统一的 Telegram 通知模块: +# - 负责消息模板拼装 +# - 负责把动态内容放进代码块,降低 MarkdownV2 出错概率 +# - 供 backup.sh 和 clean_backups.sh 共用 + tg_code_block() { local text="${1-}" text="$(printf '%s' "$text" | sed -e 's/\\/\\\\/g' -e 's/`/\\`/g')" diff --git a/readme.md b/readme.md index e28ea9c..f10ec47 100644 --- a/readme.md +++ b/readme.md @@ -69,26 +69,23 @@ EOF - `exclude.user.list`:用户自定义排除规则,不提交 Git - `exclude.user.list.example`:用户规则示例文件,可以提交 Git -排除规则由 `tar --exclude-from` 读取,匹配的是归档内的相对路径,不是磁盘上的绝对路径。 +默认规则仍然走 `tar --exclude-from`。 +用户自定义规则现在会在实际备份目录里先做一次解析,再自动换算成归档路径,所以不需要你手动猜 tar 看到的相对路径。 +如果某条用户规则当前没有命中任何实际路径,脚本会记录 `WARN`,但不会因此中止备份。 -排除单个文件: +推荐写法 1:绝对路径 ```text -project/src/main.js +/root/data/dify ``` -或者: +推荐写法 2:通配模式 ```text -*/project/src/main.js +*/.halo ``` -排除一个目录,建议写两行: - -```text -*/project/cache -*/project/cache/* -``` +对于 `*/xxxx` 这类规则,程序会在实际备份目录中先匹配命中的对象,再自动判断命中的是文件还是目录,因此用户不需要额外写一行 `/*` 来区分目录内容。 默认规则放在 `exclude.list`,业务自定义规则建议只写到 `exclude.user.list`。