#!/usr/bin/env bash # docs-sync-check.sh — 对账「本机文档镜像」与「服务器文档库」 # # 用法: # bash 07-scripts/docs-sync-check.sh # 报告双端差异 # DOCS_REMOTE=bt-server bash 07-scripts/docs-sync-check.sh # # 可覆盖的环境变量: # DOCS_LOCAL_DIR 本机镜像目录(默认 D:/github/dsh_shenxian/dsh-server-docs) # DOCS_REMOTE ssh 别名或 host(默认 bt-server) # DOCS_REMOTE_DIR 服务器文档目录(默认 /opt/dsh/docs) # # 退出码:0 = 完全一致;1 = 存在差异;2 = 环境/连接错误 set -uo pipefail LOCAL_DIR="${DOCS_LOCAL_DIR:-D:/github/dsh_shenxian/dsh-server-docs}" REMOTE="${DOCS_REMOTE:-bt-server}" REMOTE_DIR="${DOCS_REMOTE_DIR:-/opt/dsh/docs}" [ -d "$LOCAL_DIR" ] || { echo "ERROR: 本机目录不存在: $LOCAL_DIR" >&2; exit 2; } tmp_l="$(mktemp)"; tmp_r="$(mktemp)" trap 'rm -f "$tmp_l" "$tmp_r" 2>/dev/null || true' EXIT # ── 本机哈希:优先「一次 Python 算完」─────────────────────────────── # 历史坑(2026-09-12 实测):Git Bash 下 `find | while read` **逐文件 spawn** `md5sum` / `cut` # (132 文件 ≈ 264 次进程启动)→ 全量对账 **2 分 15 秒**,慢到被调用方(handoff-guard 信息模式)当成"挂死"。 # Python 一次遍历 → 秒级。探测顺序:$DSH_PY → python3 → python → 本机兜底绝对路径。 PY="${DSH_PY:-}" if [ -z "$PY" ]; then for c in python3 python; do if command -v "$c" >/dev/null 2>&1; then PY="$c"; break; fi done fi if [ -z "$PY" ] && [ -x "E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" ]; then PY="E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" fi hash_local() { if [ -n "$PY" ]; then ( cd "$LOCAL_DIR" && "$PY" -c ' import os, sys, hashlib try: sys.stdout.reconfigure(encoding="utf-8", newline="\n") except Exception: pass SKIPF = (".git/", "tmp/", "05-交接单/.doing-", "05-交接单/.exec-lock", "05-交接单/.locks/") SKIPD = (".git", "tmp", "05-交接单/.doing-", "05-交接单/.exec-lock", "05-交接单/.locks") rows = [] for root, dirs, files in os.walk("."): # ⚠️ 不能用 os.path.relpath 拼 rel —— Windows 上它会把「末尾带点」的名字规范化掉 # (实测:`INDEX.md.bak-….` 变成无点的名字 → open 失败 → 该文件被静默漏掉 → 制造假差异)。 r = root.replace(os.sep, "/") pfx = "" if r in (".", "") else (r[2:] if r.startswith("./") else r) dirs[:] = [d for d in dirs if not (((pfx + "/") if pfx else "") + d).startswith(SKIPD)] for fn in files: rel = (pfx + "/" + fn) if pfx else fn if fn == ".DS_Store" or any(rel.startswith(p) for p in SKIPF): continue if os.path.islink(rel): continue # 与 find -type f 同口径 try: with open(rel, "rb") as fh: h = hashlib.md5(fh.read()).hexdigest() except Exception: # Windows 会把「末尾带点 / 保留名」等非常规文件名规范化掉 → open 直接 ENOENT。 # 用 \\?\ 前缀绕过,**必须拼未规范化的绝对路径**(os.path.abspath 会 strip 末尾点 → 前缀失效)。 try: with open("\\\\?\\" + os.getcwd() + "\\" + rel.replace("/", "\\"), "rb") as fh: h = hashlib.md5(fh.read()).hexdigest() except Exception: continue rows.append((rel, h)) rows.sort(key=lambda r: r[0].encode("utf-8")) # 与 LC_ALL=C sort 同口径 for rel, h in rows: print(rel + "\t" + h) ' ) else echo "WARN: 未找到 python,回退逐文件 md5sum(会慢 ~2 分钟)" >&2 ( cd "$LOCAL_DIR" && find . -type f ! -path './.git/*' ! -name '.DS_Store' \ ! -path './tmp/*' ! -path '*/.locks/*' \ ! -path './05-交接单/.doing-*' ! -path './05-交接单/.exec-lock*' | LC_ALL=C sort | while read -r f; do printf '%s\t%s\n' "${f#./}" "$(md5sum "$f" | cut -d' ' -f1)" done ) fi } hash_remote() { ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE" \ "cd '$REMOTE_DIR' 2>/dev/null && find . -type f ! -path './.git/*' ! -name '.DS_Store' ! -path './tmp/*' ! -path '*/.locks/*' ! -path './05-交接单/.doing-*' ! -path './05-交接单/.exec-lock*' | LC_ALL=C sort | while read -r f; do printf '%s\t%s\n' \"\${f#./}\" \"\$(md5sum \"\$f\" | cut -d' ' -f1)\"; done" \ 2>/dev/null } hash_local > "$tmp_l" hash_remote > "$tmp_r" if [ ! -s "$tmp_r" ]; then echo "ERROR: 无法读取服务器目录 $REMOTE:$REMOTE_DIR(ssh 失败或目录不存在)" >&2 exit 2 fi awk -F'\t' ' NR==FNR { l[$1]=$2; next } { r[$1]=$2 } END { same=0 for (k in l) { if (k in r) { if (l[k]==r[k]) same++; else { diff++; print "⚠️ 内容不一致 " k } } else { only_l++; print "⬆️ 仅本地(待推送) " k } } for (k in r) if (!(k in l)) { only_r++; print "⬇️ 仅服务器(待拉取) " k } printf "\n—— 汇总 ——\n 一致: %d\n 内容不一致: %d\n 仅本地: %d\n 仅服务器: %d\n 本地文件总数: %d / 服务器文件总数: %d\n", same, diff+0, only_l+0, only_r+0, length(l), length(r) if ((diff+0)+(only_l+0)+(only_r+0) > 0) { print "\n结果: 存在差异 ❌"; exit 1 } print "\n结果: 双端一致 ✅" } ' "$tmp_l" "$tmp_r"