- data/projects/*.yaml:maka/openclaw/openwork 的 positioning 文案去掉样本计数比较级 (扩到 7 样本后「四个里唯一…」已不成立或表述失真),改事实陈述 - articles/00-overview.md(id=98 源):同步 4 处,已重发线上覆盖 - scripts/mirror_sync.sh:新增 --blobless(--filter=blob:none 懒拉 blob,大仓可省 10x 传输); 克隆重试 3→8 次且退避 10→80s,抗沙箱代理 502 抖动
466 lines
24 KiB
Bash
Executable File
466 lines
24 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 把被追踪项目的源码镜像到 gitinbox 组织,供代码级追踪。
|
||
#
|
||
# 用法:
|
||
# bash scripts/mirror_sync.sh --id ipollowork
|
||
# bash scripts/mirror_sync.sh --id ipollowork --depth 200 # 超大仓只取近期历史
|
||
# bash scripts/mirror_sync.sh --id orca --blobless # 大仓懒拉 blob,只走源码快照
|
||
#
|
||
# 可见性由档案的 business.license.redistributable 决定(L1/L2 → public,L3 → private)。
|
||
# 这条规则不能手工绕过:把 source-available 项目公开镜像,等于替对方做了一次
|
||
# 未经授权的公开分发。
|
||
set -euo pipefail
|
||
|
||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
GITEA_URL="${GITEA_URL:-https://gitea.apescale.com}"
|
||
DISPLAY_URL="${GITEA_DISPLAY_URL:-https://gitea.apescale.com}"
|
||
ORG="${GITEA_ORG:-gitinbox}"
|
||
ID=""
|
||
DEPTH=""
|
||
# auto = 体积超限时自动降级为源码快照;no = 始终尝试完整镜像(超限会 413 失败)。
|
||
# 这两个变量必须在 set -u 之前就有值:下面体积闸门的条件里会引用它们,
|
||
# 而这个分支只在特定体积下才走到——不初始化就成了「偶尔才炸」的雷。
|
||
SOURCE_ONLY="auto"
|
||
SOURCE_ONLY_FORCE="no"
|
||
# private → public 的显式授权开关。默认 0:公开不可逆,绝不自动放宽。
|
||
SET_PUBLIC="0"
|
||
EXCLUDE_COUNT="0"
|
||
# 跳过上游更新(本地裸仓已完整,只为重做快照时用)
|
||
NO_FETCH="0"
|
||
# blobless 克隆:--filter=blob:none,初始只拉 commits+trees,checkout 时按需懒拉 blob。
|
||
# 大仓(尤其 blob 占比高)可把传输量从整包几百 MB 砍到几十 MB,且具备"克隆成功即落盘、
|
||
# 之后懒拉 blob 失败可重跑续传"的容错;代价是只能走源码快照(partial clone 不能按 mirror 推送)。
|
||
BLOBLESS="0"
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--id) ID="$2"; shift 2;;
|
||
--org) ORG="$2"; shift 2;;
|
||
--url) GITEA_URL="$2"; shift 2;;
|
||
--depth) DEPTH="$2"; shift 2;;
|
||
--no-source-only) SOURCE_ONLY="no"; shift;;
|
||
--set-public) SET_PUBLIC="1"; shift;;
|
||
--no-fetch) NO_FETCH="1"; shift;;
|
||
--blobless) BLOBLESS="1"; shift;;
|
||
*) echo "未知参数: $1" >&2; exit 1;;
|
||
esac
|
||
done
|
||
|
||
[[ -z "${ID}" ]] && { echo "缺少 --id" >&2; exit 1; }
|
||
YAML="${ROOT}/data/projects/${ID}.yaml"
|
||
[[ -f "${YAML}" ]] || { echo "档案不存在: ${YAML}" >&2; exit 1; }
|
||
|
||
# ---- 从档案读取镜像配置(bash 不适合解析 YAML,借用 python 输出 shell 赋值) ----
|
||
# 用"写临时文件再 source"而不是 eval "$(...)":
|
||
# eval 会二次解析内容,YAML 里的括号/引号一旦漏转义就会变成难定位的
|
||
# syntax error,而且报错行号会偏移到完全无关的位置。
|
||
CONF_FILE="$(mktemp)"
|
||
trap 'rm -f "${CONF_FILE}"' EXIT
|
||
|
||
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "${YAML}" > "${CONF_FILE}" <<'PY'
|
||
import sys, shlex
|
||
from pathlib import Path
|
||
sys.path.insert(0, str(Path(sys.argv[1]).resolve().parent.parent.parent / "scripts"))
|
||
from _common import load_yaml
|
||
d = load_yaml(Path(sys.argv[1]))
|
||
m = d.get("mirror") or {}
|
||
lic = (d.get("business") or {}).get("license") or {}
|
||
print(f"UPSTREAM={shlex.quote(str(m.get('upstream') or ''))}")
|
||
print(f"REPO_NAME={shlex.quote(str(m.get('gitea_repo') or '').split('/')[-1] or d.get('id',''))}")
|
||
# 可见性以许可证判定为准,不信任档案里手写的 visibility
|
||
exc = m.get("excludes") or []
|
||
if exc:
|
||
# 目录切分许可:被排除的目录不进镜像,所以决定可见性的是**排除后**的内容。
|
||
# 这里必须显式读 redistributable_after_excludes,缺了就报错——
|
||
# 不能拿整体许可证去猜:openwork 整体是 L3(含 ee/),排除后是纯 MIT,
|
||
# 两种情况结论相反,猜错就是把受限内容公开出去,或者把能公开的白白锁起来。
|
||
ra = lic.get("redistributable_after_excludes")
|
||
if ra is None:
|
||
sys.stderr.write(
|
||
"档案声明了 mirror.excludes,但 business.license 里没有 "
|
||
"redistributable_after_excludes,无法判定排除后的可见性。\n"
|
||
"请在许可证块补上该字段(true=排除后可公开,false=仍须私有)。\n")
|
||
sys.exit(2)
|
||
vis = "public" if ra else "private"
|
||
else:
|
||
vis = "public" if lic.get("redistributable") else "private"
|
||
print(f"VISIBILITY={shlex.quote(vis)}")
|
||
print(f"LIC_LEVEL={shlex.quote(str(lic.get('openness') or '?'))}")
|
||
print(f"LIC_NAME={shlex.quote(str(lic.get('name') or ''))}")
|
||
# 目录切分许可的排除项(如 OpenWork 的 ee/)。
|
||
# 必须读档案 mirror.excludes,不能读 registry.yaml 的 mirror_excludes:
|
||
# 后者只是批量抓取的提示,脚本从来没读过它,写在那里等于没写——
|
||
# openwork 的 visibility: public 曾因此建立在一条从未生效的规则上。
|
||
print("EXCLUDES=" + shlex.quote("\n".join(str(x) for x in (m.get("excludes") or []))))
|
||
PY
|
||
|
||
# shellcheck disable=SC1090
|
||
source "${CONF_FILE}"
|
||
|
||
# 换行分隔 → 数组。空输入要能正确得到零长数组,否则下面 ${#EXCLUDE_PATHS[@]}
|
||
# 在 set -u 下会因未绑定而炸(这类"偶尔才走到"的分支最容易埋雷)。
|
||
EXCLUDE_PATHS=()
|
||
if [[ -n "${EXCLUDES}" ]]; then
|
||
while IFS= read -r _line; do
|
||
[[ -n "${_line}" ]] && EXCLUDE_PATHS+=("${_line}")
|
||
done <<< "${EXCLUDES}"
|
||
fi
|
||
EXCLUDE_COUNT=${#EXCLUDE_PATHS[@]}
|
||
|
||
[[ -z "${UPSTREAM}" ]] && { echo "档案缺少 mirror.upstream" >&2; exit 1; }
|
||
|
||
echo "==> 镜像 ${ID}"
|
||
echo " 上游 : ${UPSTREAM}"
|
||
echo " 目标 : ${ORG}/${REPO_NAME}"
|
||
echo " 许可证 : ${LIC_LEVEL} (${LIC_NAME})"
|
||
echo " 可见性 : ${VISIBILITY}"
|
||
if [[ "${VISIBILITY}" == "private" ]]; then
|
||
echo " ⚠ L3 级 source-available,已强制私有。公开镜像可能构成未经授权的分发。"
|
||
fi
|
||
if [[ ${#EXCLUDE_PATHS[@]} -gt 0 ]]; then
|
||
echo " 排除路径 : ${EXCLUDE_PATHS[*]}"
|
||
echo " ⚠ 本项目的 public 可见性建立在「上述路径不进镜像」之上,推送前会二次校验。"
|
||
fi
|
||
|
||
# ---- 凭据:优先环境变量,回退到 macOS 钥匙串 ----
|
||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||
CRED="$(printf "host=%s\nprotocol=https\n" "${DISPLAY_URL#*://}" | git credential-osxkeychain get 2>/dev/null || true)"
|
||
GITEA_USER="${GITEA_USER:-$(printf '%s' "${CRED}" | sed -n 's/^username=//p')}"
|
||
GITEA_TOKEN="$(printf '%s' "${CRED}" | sed -n 's/^password=//p')"
|
||
fi
|
||
[[ -z "${GITEA_TOKEN:-}" || -z "${GITEA_USER:-}" ]] && {
|
||
echo "未拿到 Gitea 凭据(设 GITEA_TOKEN/GITEA_USER,或存入钥匙串)" >&2; exit 1; }
|
||
|
||
# ---- 确保目标仓库存在(组织端点,不是 /user/repos) ----
|
||
API="${GITEA_URL}/api/v1"
|
||
HTTP="$(curl -sS -o /dev/null -w '%{http_code}' -u "${GITEA_USER}:${GITEA_TOKEN}" "${API}/repos/${ORG}/${REPO_NAME}" || echo 000)"
|
||
if [[ "${HTTP}" == "200" ]]; then
|
||
echo " 仓库已存在"
|
||
elif [[ "${HTTP}" == "404" ]]; then
|
||
echo " 创建仓库 ${ORG}/${REPO_NAME} (private=$([[ ${VISIBILITY} == private ]] && echo true || echo false)) ..."
|
||
CREATE="$(curl -sS -o /tmp/gitea_mirror_create.json -w '%{http_code}' -u "${GITEA_USER}:${GITEA_TOKEN}" \
|
||
-H 'Content-Type: application/json' -X POST "${API}/org/${ORG}/repos" \
|
||
-d "{\"name\":\"${REPO_NAME}\",\"private\":$([[ ${VISIBILITY} == private ]] && echo true || echo false),\"auto_init\":false,\"description\":\"Mirror of ${UPSTREAM} (license: ${LIC_NAME})\"}")"
|
||
[[ "${CREATE}" =~ ^(200|201)$ ]] || {
|
||
echo "创建失败 (HTTP ${CREATE}):" >&2; cat /tmp/gitea_mirror_create.json >&2; exit 1; }
|
||
echo " 已创建"
|
||
else
|
||
echo "查询仓库失败 (HTTP ${HTTP})" >&2; exit 1
|
||
fi
|
||
|
||
# 已存在的仓库若应为私有,显式修正(创建时的 private 对已存在仓库不生效)
|
||
# 收紧方向永远自动做:把一个本该私有的仓库公开出去是事故,反过来锁起来只是不方便。
|
||
if [[ "${VISIBILITY}" == "private" ]]; then
|
||
curl -sS -o /dev/null -u "${GITEA_USER}:${GITEA_TOKEN}" -X PATCH \
|
||
-H 'Content-Type: application/json' -d '{"private":true}' "${API}/repos/${ORG}/${REPO_NAME}" || true
|
||
fi
|
||
|
||
# ---- 放宽方向(private → public)推迟到推送成功之后做 ----
|
||
# 2026-09-02 的教训:这段原先放在同步之前,结果 openwork 翻成了 public
|
||
# 但推送失败(source-only 快照历史不连续被拒),
|
||
# 于是出现「仓库已公开 + 内容仍是旧快照」的窗口期——
|
||
# 虽然旧内容也不含 ee/(合法),但公开了不该公开的版本,正是最该避免的局面。
|
||
# 正确顺序:先推成功、内容对了,再翻可见性。任一步失败都停在 private,无害。
|
||
# 这里只做一次探测,真正的翻转在文件末尾 apply_visibility()。
|
||
if [[ "${VISIBILITY}" == "public" ]]; then
|
||
CUR_PRIVATE="$(curl -sS -u "${GITEA_USER}:${GITEA_TOKEN}" "${API}/repos/${ORG}/${REPO_NAME}" \
|
||
| /Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c 'import sys,json;print("true" if json.load(sys.stdin).get("private") else "false")' 2>/dev/null || echo unknown)"
|
||
fi
|
||
|
||
# ---- 同步 ----
|
||
MIRROR_DIR="${ROOT}/.mirrors/${REPO_NAME}.git"
|
||
mkdir -p "${ROOT}/.mirrors"
|
||
|
||
DEEPEN_STEP="${DEEPEN_STEP:-100}"
|
||
|
||
# 注意:不能用 `rev-parse --git-dir` 判断仓库是否有效。git clone 会先建好
|
||
# 目录骨架再下载 pack,被中断的半成品照样能通过 rev-parse,于是被当成
|
||
# "已有仓库"走 fetch 分支,报出 "CONNECT tunnel failed" 这类误导性错误。
|
||
# 真正的判据是"里面有没有提交"。
|
||
repo_ok() {
|
||
[[ -d "${MIRROR_DIR}" ]] || return 1
|
||
git -C "${MIRROR_DIR}" rev-parse --git-dir >/dev/null 2>&1 || return 1
|
||
local n
|
||
n="$(git -C "${MIRROR_DIR}" rev-list --count --all 2>/dev/null || echo 0)"
|
||
[[ "${n}" -gt 0 ]]
|
||
}
|
||
is_shallow() { [[ "$(git -C "${MIRROR_DIR}" rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; }
|
||
|
||
# clone/fetch 失败时清掉半成品,避免下次误判(脚本有 set -e,失败会直接退出)
|
||
cleanup_broken() {
|
||
if [[ -d "${MIRROR_DIR}" ]] && ! repo_ok; then
|
||
echo " 清理未完成的半成品裸仓 ..."
|
||
rm -rf "${MIRROR_DIR}"
|
||
fi
|
||
}
|
||
trap cleanup_broken EXIT
|
||
|
||
if [[ -d "${MIRROR_DIR}" ]] && ! repo_ok; then
|
||
echo " 清理上次中断的半成品裸仓 ..."
|
||
rm -rf "${MIRROR_DIR}"
|
||
fi
|
||
|
||
if [[ "${NO_FETCH}" == "1" ]]; then
|
||
# 跳过上游更新,直接用本地裸仓生成快照。
|
||
# 2026-09-02 需要这个:本地裸仓已是完整的,只想重跑一次快照(改了排除项),
|
||
# 但 GitHub 恰好连不上("Empty reply from server"),脚本在 fetch 阶段就退出,
|
||
# 明明有数据却什么都做不了——上游不可用时,不该阻止我们用已有内容重做快照。
|
||
if repo_ok; then
|
||
echo " 跳过上游更新(--no-fetch),直接用本地裸仓生成快照"
|
||
else
|
||
echo "✗ --no-fetch 但本地裸仓不可用:${MIRROR_DIR}" >&2; exit 1
|
||
fi
|
||
elif repo_ok; then
|
||
if is_shallow && [[ -z "${DEPTH}" ]]; then
|
||
# 慢链路续传:分批 --deepen。每批成功即落盘,中断后重跑本脚本可继续,
|
||
# 不像 `git clone` 那样失败一次全部作废。
|
||
echo " 浅仓分批续传(每批 --deepen=${DEEPEN_STEP},中断可重跑)..."
|
||
while is_shallow; do
|
||
PRE="$(git -C "${MIRROR_DIR}" rev-list --count --all 2>/dev/null || echo 0)"
|
||
echo " 已有 ${PRE} 条历史,再加深 ${DEEPEN_STEP} ..."
|
||
if ! git -C "${MIRROR_DIR}" fetch --deepen="${DEEPEN_STEP}" origin; then
|
||
echo " ⚠ 本批中断;已落盘的 ${PRE} 条历史保留,重跑本脚本继续。"
|
||
break
|
||
fi
|
||
POST="$(git -C "${MIRROR_DIR}" rev-list --count --all 2>/dev/null || echo 0)"
|
||
[[ "${POST}" == "${PRE}" ]] && { echo " 已补齐完整历史"; break; }
|
||
done
|
||
elif is_shallow && [[ -n "${DEPTH}" ]]; then
|
||
echo " 浅仓加深到 depth=${DEPTH} ..."
|
||
git -C "${MIRROR_DIR}" fetch --depth="${DEPTH}" origin || \
|
||
echo " ⚠ 加深中断,已保留现有深度,重跑可继续"
|
||
else
|
||
echo " 更新已有裸仓 ..."
|
||
git -C "${MIRROR_DIR}" remote update --prune
|
||
fi
|
||
else
|
||
# 走沙箱代理时偶发 `CONNECT tunnel failed, response 502`,且 git clone
|
||
# 失败不保留已下载内容,所以首次克隆必须带重试。
|
||
CLONE_ARGS=(clone --mirror)
|
||
[[ -n "${DEPTH}" ]] && CLONE_ARGS+=(--depth "${DEPTH}")
|
||
[[ "${BLOBLESS}" == "1" ]] && CLONE_ARGS+=(--filter=blob:none)
|
||
if [[ -n "${DEPTH}" ]]; then
|
||
echo " 首次镜像(--depth ${DEPTH},只取近期历史)..."
|
||
else
|
||
echo " 首次全量镜像(大仓请耐心)..."
|
||
fi
|
||
# 沙箱代理对 GitHub 偶发 502(CONNECT tunnel failed),且大仓传输动辄几小时,
|
||
# 一次 502 就可能断在半途。所以克隆重试要够多、退避够长:8 次、10→80s 递增。
|
||
# blobless 模式下"克隆成功即落盘"还能在 checkout 阶段继续容错(见下)。
|
||
for attempt in $(seq 1 8); do
|
||
if git "${CLONE_ARGS[@]}" "${UPSTREAM}" "${MIRROR_DIR}"; then
|
||
break
|
||
fi
|
||
echo " ⚠ 第 ${attempt} 次克隆失败,$(( attempt * 10 ))s 后重试 ..."
|
||
rm -rf "${MIRROR_DIR}"
|
||
sleep $(( attempt * 10 ))
|
||
[[ ${attempt} -eq 8 ]] && { echo "克隆八次均失败,请检查网络/代理" >&2; exit 1; }
|
||
done
|
||
fi
|
||
|
||
# ---- 浅仓处理:服务端拒绝 shallow update,必须先补齐历史 ----
|
||
# Git 服务端默认 `receive.shallowUpdate = deny`,推送浅仓会被拒:
|
||
# ! [remote rejected] main -> main (shallow update not allowed)
|
||
# 而 --depth 是本脚本自己提供的选项,推不上去等于自相矛盾,所以在这里兜住:
|
||
# 先 --unshallow 补齐(增量下载,通常只比浅仓多几十 MB),失败再降级为快照。
|
||
UNSHALLOWED="no"
|
||
if is_shallow; then
|
||
echo " 浅仓无法直接推送(服务端拒绝 shallow update),先补齐完整历史 ..."
|
||
for attempt in 1 2 3; do
|
||
if git -C "${MIRROR_DIR}" fetch --unshallow origin 2>/dev/null; then
|
||
UNSHALLOWED="yes"; break
|
||
fi
|
||
echo " ⚠ 第 ${attempt} 次 unshallow 失败,10s 后重试 ..."
|
||
sleep 10
|
||
done
|
||
if [[ "${UNSHALLOWED}" == "yes" ]]; then
|
||
echo " 已补齐完整历史"
|
||
else
|
||
# 网络或体积原因补不齐 —— 退回快照模式,丢历史但至少把代码留下来。
|
||
echo " ⚠ 补齐失败,将降级为【源码快照】(会丢失提交历史)"
|
||
SOURCE_ONLY_FORCE="yes"
|
||
fi
|
||
fi
|
||
|
||
PROTO="${GITEA_URL%%://*}"
|
||
HOST="${GITEA_URL#*//}"
|
||
U_ENC="$(/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c \
|
||
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "${GITEA_USER}")"
|
||
P_ENC="$(/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c \
|
||
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "${GITEA_TOKEN}")"
|
||
|
||
# ---- 体积闸门:Gitea 前置 nginx 有 client_max_body_size 上限 ----
|
||
# 实测 gitea.apescale.com:48 MB 放行、64 MB 拒绝(推算为 50m,宝塔常见默认值)。
|
||
# 超限推送会在传完全部对象后才收到 HTTP 413,白白浪费几十分钟下载。
|
||
# 所以推送前先量体积,超限则自动降级为「源码快照」:剔除图片/音视频/字体等
|
||
# 非源码素材——这些内容通常占一半以上体积,却不是"跟踪源码"要看的东西。
|
||
MAX_PUSH_MB="${MAX_PUSH_MB:-45}"
|
||
PACK_KB="$(git -C "${MIRROR_DIR}" count-objects -v | awk '/^size-pack:/{print $2}')"
|
||
PACK_MB=$(( PACK_KB / 1024 ))
|
||
# blobless 镜像只能走源码快照:partial clone 不能按 mirror 推送。
|
||
[[ "${BLOBLESS}" == "1" ]] && SOURCE_ONLY_FORCE="yes"
|
||
MIRROR_MODE="full"
|
||
|
||
echo " 本地裸仓打包体积: ${PACK_MB} MB(推送上限 ${MAX_PUSH_MB} MB)"
|
||
|
||
if [[ ( "${PACK_MB}" -gt "${MAX_PUSH_MB}" || "${SOURCE_ONLY_FORCE}" == "yes" ) \
|
||
&& "${SOURCE_ONLY}" != "no" ]]; then
|
||
MIRROR_MODE="source-only"
|
||
if [[ "${SOURCE_ONLY_FORCE}" == "yes" ]]; then
|
||
echo " ⚠ 降级为【源码快照】:提交历史不完整,按快照提交(仍会剔除素材)"
|
||
else
|
||
echo " ⚠ 超出上限,降级为【源码快照】:剔除非源码素材后推送"
|
||
fi
|
||
SNAP="$(mktemp -d)"
|
||
# shellcheck disable=SC2064
|
||
trap "cleanup_broken; rm -rf '${SNAP}'" EXIT
|
||
|
||
git init -q "${SNAP}/repo"
|
||
git --git-dir="${MIRROR_DIR}" --work-tree="${SNAP}/repo" checkout HEAD -- . 2>/dev/null
|
||
|
||
# 目录切分许可的排除在这里落地:工作树 checkout 完之后整棵删掉。
|
||
# 注意必须在资产剔除**之前**做——ee/ 这类目录里也可能有图片/字体,
|
||
# 顺序反了会留下空目录和残留文件。
|
||
if [[ ${#EXCLUDE_PATHS[@]} -gt 0 ]]; then
|
||
for _exc in "${EXCLUDE_PATHS[@]}"; do
|
||
if [[ -e "${SNAP}/repo/${_exc}" ]]; then
|
||
rm -rf "${SNAP}/repo/${_exc}"
|
||
echo " 已排除 ${_exc}"
|
||
else
|
||
# 排除了个不存在的路径,多半是上游改了目录结构。
|
||
# 不静默放过——否则哪天 ee/ 挪了窝,我们还在按 public 推。
|
||
echo " ⚠ 声明要排除的 ${_exc} 在上游不存在,请核对档案的 excludes 是否过期"
|
||
fi
|
||
done
|
||
fi
|
||
|
||
find "${SNAP}/repo" -type f -not -path '*/.git/*' \( \
|
||
-iname '*.png' -o -iname '*.gif' -o -iname '*.jpg' -o -iname '*.jpeg' \
|
||
-o -iname '*.webp' -o -iname '*.ico' -o -iname '*.mp4' -o -iname '*.mov' \
|
||
-o -iname '*.webm' -o -iname '*.wav' -o -iname '*.mp3' -o -iname '*.glb' \
|
||
-o -iname '*.ttf' -o -iname '*.woff' -o -iname '*.woff2' -o -iname '*.eot' \
|
||
-o -iname '*.pdf' -o -iname '*.zip' \) -delete
|
||
|
||
cat > "${SNAP}/repo/MIRROR-NOTE.md" <<NOTE
|
||
# 镜像说明
|
||
|
||
- 上游:${UPSTREAM}
|
||
- 许可证:${LIC_NAME}(${LIC_LEVEL})
|
||
- 抓取日期:$(date +%F)
|
||
|
||
## 这不是完整保真镜像
|
||
|
||
本仓库只含**源码与文档**,已剔除图片、音视频、字体等非源码素材。
|
||
$(if [[ ${#EXCLUDE_PATHS[@]} -gt 0 ]]; then
|
||
printf '此外按许可证要求排除了以下目录:%s。\n' "${EXCLUDE_PATHS[*]}"
|
||
printf '本仓库的公开可见性以该排除为前提;完整上游含受限许可内容。\n'
|
||
fi)
|
||
原因:Gitea 前置 nginx 的 \`client_max_body_size\` 上限约 ${MAX_PUSH_MB} MB,
|
||
完整裸仓打包 ${PACK_MB} MB 会被 HTTP 413 拒绝。
|
||
|
||
完整保真快照保留在追踪体系的本地裸仓 \`.mirrors/${REPO_NAME}.git\` 中。
|
||
把服务器 nginx 放开到 \`client_max_body_size 512m;\` 后即可推送完整版。
|
||
NOTE
|
||
|
||
PUSH_DIR="${SNAP}/repo"
|
||
( cd "${PUSH_DIR}" && git add -A \
|
||
&& git -c user.name='agent-radar' -c user.email='radar@local' \
|
||
commit -q -m "源码快照 @ $(date +%F) (source-only, 剔除非源码素材)" )
|
||
# 必须先 repack 再量:新 commit 出来的都是 loose object,
|
||
# size-pack 为 0,会打印出「源码快照体积: 0 MB」这种误导性输出。
|
||
# repack 顺带也把推送体积压下去(实测 iPolloWork:散装 57 MB → 打包 14 MB)。
|
||
git -C "${PUSH_DIR}" repack -a -d -q --window=50 --depth=50 2>/dev/null || true
|
||
SNAP_MB=$(( $(git -C "${PUSH_DIR}" count-objects -v | awk '/^size-pack:/{print $2}') / 1024 ))
|
||
echo " 源码快照体积: ${SNAP_MB} MB"
|
||
else
|
||
PUSH_DIR="${MIRROR_DIR}"
|
||
fi
|
||
|
||
# ---- 推送前硬校验:排除项绝不能进公开镜像 ----
|
||
# 这是整套许可证分级的最后一道闸。前面所有环节(声明、checkout 后删除)都可能有
|
||
# 遗漏或顺序问题,而"public"这个动作的代价是不可逆的——东西一旦公开就收不回来。
|
||
# 所以这里不看过程只看结果:直接检查待推送目录里还有没有这些路径。
|
||
if [[ "${VISIBILITY}" == "public" && ${#EXCLUDE_PATHS[@]} -gt 0 ]]; then
|
||
if [[ "${MIRROR_MODE}" == "full" ]]; then
|
||
# full 模式直接拒:push --mirror 推的是完整裸仓,剔除目录需要重写历史
|
||
# (git filter-repo),本脚本不做。更关键的是——裸仓没有工作树,
|
||
# 文件系统层面的检查会**假通过**,而 ee/ 的内容在历史里照样推得出去。
|
||
# 这种"检查了但没真检查到"比不检查更危险,所以这里不看结果直接拦。
|
||
echo "" >&2
|
||
echo "✗ 拒绝以 full 模式推送到公开仓库:${EXCLUDE_PATHS[*]}" >&2
|
||
echo " full 模式用 push --mirror 推完整裸仓,无法在此层面剔除受限目录," >&2
|
||
echo " 且历史提交里仍含该内容。请二选一:" >&2
|
||
echo " a) 把档案 mirror.visibility 改为 private;" >&2
|
||
echo " b) 让体积超过闸门(MAX_PUSH_MB=${MAX_PUSH_MB})走 source-only 路径,排除在该路径生效。" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# source-only:有真实工作树,可以做结果校验。
|
||
# 不看"我们有没有执行删除",只看"待推送内容里到底还有没有"——
|
||
# 过程可能被改坏,结果不会说谎。
|
||
LEAK=()
|
||
for _exc in "${EXCLUDE_PATHS[@]}"; do
|
||
_base="${_exc%/}"
|
||
[[ -e "${PUSH_DIR}/${_base}" ]] && LEAK+=("${_base}")
|
||
done
|
||
if [[ ${#LEAK[@]} -gt 0 ]]; then
|
||
echo "" >&2
|
||
echo "✗ 拒绝推送:以下受限目录仍在待推送内容中:${LEAK[*]}" >&2
|
||
echo " 目标仓库是 **public**,而其中含受限许可内容。" >&2
|
||
exit 1
|
||
fi
|
||
echo " 排除校验通过:${EXCLUDE_PATHS[*]} 均不存在于待推送内容"
|
||
fi
|
||
|
||
echo " 推送到 ${ORG}/${REPO_NAME} ..."
|
||
if [[ "${MIRROR_MODE}" == "full" ]]; then
|
||
git -C "${PUSH_DIR}" push --mirror "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git"
|
||
else
|
||
# source-only 必须强推:每次快照都是「checkout → git init → 单次 commit」,
|
||
# 与远端上一次的快照 commit 毫无血缘关系,永远不可能 fast-forward。
|
||
# 2026-09-02 踩到:改了排除项后重跑,推送被拒
|
||
# "! [rejected] HEAD -> main (fetch first)"
|
||
# 但排除校验明明通过了——不是排除没生效,是历史不连续。
|
||
# 强推在这里语义正确:source-only 镜像保的是**当前源码状态**,不是历史链。
|
||
# 若哪天需要保留历史,该做的是增量快照而非回滚这个 force。
|
||
echo " (source-only 快照,强推覆盖:历史不连续是设计使然)"
|
||
git -C "${PUSH_DIR}" push --force "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git" HEAD:refs/heads/main
|
||
fi
|
||
|
||
# ---- 可见性放宽:走到这里说明内容已经推对了,才允许公开 ----
|
||
# 见上方 2026-09-02 的说明:翻转必须发生在成功推送之后。
|
||
# 收紧方向(private)在同步前就做了,因为锁起来永远安全、早做没代价。
|
||
if [[ "${VISIBILITY}" == "public" && "${CUR_PRIVATE}" == "true" ]]; then
|
||
if [[ "${SET_PUBLIC}" == "1" ]]; then
|
||
echo " ⚠ 内容已推送且排除校验通过 → 将 ${ORG}/${REPO_NAME} 翻为 public(不可逆)"
|
||
curl -sS -o /dev/null -u "${GITEA_USER}:${GITEA_TOKEN}" -X PATCH \
|
||
-H 'Content-Type: application/json' -d '{"private":false}' "${API}/repos/${ORG}/${REPO_NAME}" \
|
||
&& echo " 已翻为 public"
|
||
else
|
||
echo ""
|
||
echo " ⚠ ${ORG}/${REPO_NAME} 判定应为 public,但远端当前是 private。"
|
||
echo " 内容已推送且排除校验通过;公开不可逆,确认后重跑并加 --set-public。"
|
||
echo " 判定依据:许可证 ${LIC_LEVEL},排除 ${EXCLUDE_COUNT} 项后 redistributable=public"
|
||
fi
|
||
fi
|
||
|
||
# ---- 回写档案 ----
|
||
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "${YAML}" "${VISIBILITY}" "${REPO_NAME}" "${MIRROR_MODE}" <<'PY'
|
||
import sys
|
||
from pathlib import Path
|
||
sys.path.insert(0, str(Path(sys.argv[1]).resolve().parent.parent.parent / "scripts"))
|
||
from _common import today, update_block
|
||
p = Path(sys.argv[1])
|
||
text = p.read_text(encoding="utf-8")
|
||
text = update_block(text, "mirror", {
|
||
"visibility": sys.argv[2],
|
||
"gitea_repo": f"{'gitinbox'}/{sys.argv[3]}",
|
||
"last_sync": today(),
|
||
"mode": sys.argv[4],
|
||
})
|
||
p.write_text(text, encoding="utf-8")
|
||
PY
|
||
|
||
SIZE="$(du -sh "${MIRROR_DIR}" 2>/dev/null | cut -f1 || echo '?')"
|
||
echo "==> 完成。${DISPLAY_URL}/${ORG}/${REPO_NAME} (模式 ${MIRROR_MODE},本地裸仓 ${SIZE})"
|