Files
aerologic-app/.claude/skills/sync-air-cargo/scripts/sync.sh
YANG JIANKUAN 5b217ab032 feat(sync-air-cargo): 以 Synced-From trailer 记录同步点,同步后自动在 air-cargo 本地提交
- 默认区间模式:基线取 air-cargo 最近的 Synced-From,目标为 main,内容取自提交对象
- 无记录时按内容反推基线(仅预览,apply 须 --base 确认),每次预览做基线校验
- 新增三方校验,列出将覆盖的 air-cargo 侧独立修改;分支/工作区/祖先关系保护
- --worktree 同步未提交变更且不推进同步点;提交信息仅「<Label> <日期>」+ trailer,不 push
- 新增 config(main → feature/hefei,air-cargo 位于项目根目录)
- 排除清单补充 .mcp.json、.claude.*、CLAUDE.local.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:20:55 -07:00

277 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# 将 aerologic-app 的变更以文件级 cp/rm 同步到 air-cargo,并在 air-cargo 本地提交(不 push)。
# 两个目录是同一份项目源码的独立 git 仓库副本,只做文件级复制/删除,不做任何内容编辑。
#
# 同步点记录在 air-cargo 提交信息的 trailer「Synced-From: aerologic-app@<sha>」中:
# - 区间模式(默认):基线 = air-cargo 当前分支最近一个 Synced-From,目标 = 源仓库 SOURCE_BRANCH 最新提交。
# 文件内容取自目标提交对象,与源仓库工作区无关;提交时写入新的 Synced-From,推进同步点
# - 工作区模式(--worktree):同步源仓库未提交的变更;提交时不写 Synced-From,不推进同步点
# 没有同步记录时按内容反推基线(目标第一父链上与 air-cargo HEAD 差异最少的提交),仅供确认,apply 须显式 --base
set -euo pipefail
export GIT_LITERAL_PATHSPECS=1
usage() {
cat <<'EOF'
用法: sync.sh [preview|apply] [选项]
preview 只打印同步计划,不做任何改动(默认)
apply 复制/删除文件并在 air-cargo 本地提交(不 push)
选项:
--base <ref> 指定基线:没有 Synced-From 记录(首次同步)或需要纠正时使用
--to <ref> 指定目标提交,默认 config 中的 SOURCE_BRANCH
--worktree 工作区模式:同步源仓库未提交的变更,不推进同步点
--label <word> 提交信息的通用词,默认 Update(可用 Fixed、Optimized 等)
EOF
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../config
. "$SCRIPT_DIR/../config"
SOURCE_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
TARGET_DIR="$SOURCE_DIR/$TARGET_SUBDIR"
SOURCE_NAME="$(basename "$SOURCE_DIR")"
MODE=preview BASE_ARG="" TO_ARG="" WORKTREE=0 LABEL=Update
while [ $# -gt 0 ]; do
case "$1" in
preview|apply) MODE="$1" ;;
--base) BASE_ARG="${2:?--base 缺少参数}"; shift ;;
--to) TO_ARG="${2:?--to 缺少参数}"; shift ;;
--worktree) WORKTREE=1 ;;
--label) LABEL="${2:?--label 缺少参数}"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数:$1" >&2; usage >&2; exit 1 ;;
esac
shift
done
die() { echo "错误:$*" >&2; exit 1; }
S() { git -C "$SOURCE_DIR" "$@"; }
T() { git -C "$TARGET_DIR" "$@"; }
short() { S rev-parse --short "$1"; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# 只同步工程项目本身的文件,Claude Code 相关的配置/记忆文件(CLAUDE.md、CLAUDE.local.md、.claude/ 目录、根目录 .claude.* 与 .mcp.json,
# 包括其中的 skills/commands 等)两个仓库各自独立维护,不参与同步。
is_excluded() {
case "$1" in
.claude|.claude/*|.claude.*|.mcp.json) return 0 ;;
CLAUDE.md|*/CLAUDE.md|CLAUDE.local.md|*/CLAUDE.local.md) return 0 ;;
esac
return 1
}
# 列出提交中参与同步的文件「路径<TAB>blob」(已排序),用于按内容比对两个仓库
filtered_tree() {
git -C "$1" -c core.quotepath=false ls-tree -r "$2" | awk -F'\t' '
$2 ~ /^\.claude($|\/|\.)/ || $2 == ".mcp.json" || $2 ~ /(^|\/)CLAUDE(\.local)?\.md$/ { next }
{ split($1, m, " "); print $2 "\t" m[3] }' | LC_ALL=C sort
}
# 两份 filtered_tree 输出中内容不同的路径(去重)
diff_paths() {
LC_ALL=C comm -3 "$1" "$2" | sed $'s/^\t//' | cut -f1 | LC_ALL=C sort -u
}
blob_at() { S rev-parse -q --verify "$1:$2" 2>/dev/null || true; }
# ---------- 前置检查 ----------
[ -d "$SOURCE_DIR/.git" ] || die "$SOURCE_DIR 不是 git 仓库根目录"
[ -d "$TARGET_DIR/.git" ] || die "目标仓库不存在:$TARGET_DIR"
cur_branch="$(T branch --show-current)"
[ "$cur_branch" = "$TARGET_BRANCH" ] ||
die "air-cargo 当前分支为「${cur_branch:-游离 HEAD}」,应为「${TARGET_BRANCH}」(见 config),请先与用户确认"
# ---------- 确定基线与目标 ----------
BASE="" BASE_FROM="" TO="" INFERRED=0
if [ "$WORKTREE" -eq 1 ]; then
[ -z "$BASE_ARG$TO_ARG" ] || die "--worktree 不能与 --base / --to 同时使用"
BASE="$(S rev-parse HEAD)"
BASE_FROM="源仓库 HEAD(工作区模式)"
else
TO="$(S rev-parse -q --verify "${TO_ARG:-$SOURCE_BRANCH}^{commit}")" || die "源仓库找不到 ${TO_ARG:-$SOURCE_BRANCH}"
if [ -n "$BASE_ARG" ]; then
BASE="$(S rev-parse -q --verify "$BASE_ARG^{commit}")" || die "源仓库找不到基线 $BASE_ARG"
BASE_FROM="--base 参数"
else
record="$(T log -1 --grep='^Synced-From: ' --format='%(trailers:key=Synced-From,valueonly)' | sed -n '1p')"
if [ -n "$record" ]; then
BASE="$(S rev-parse -q --verify "${record##*@}^{commit}")" ||
die "air-cargo 记录的同步点 ${record##*@} 在源仓库中不存在(源仓库被 rebase / force-push 过?),请用 --base 指定"
BASE_FROM="air-cargo 提交记录中的 Synced-From"
fi
fi
filtered_tree "$TARGET_DIR" HEAD > "$TMP/target.tree"
if [ -z "$BASE" ]; then
# 兜底:按内容反推,在目标的第一父链上找与 air-cargo HEAD 差异最少的提交
best_n=-1
for c in $(S rev-list --first-parent --max-count=300 "$TO"); do
filtered_tree "$SOURCE_DIR" "$c" > "$TMP/cand.tree"
n="$(diff_paths "$TMP/cand.tree" "$TMP/target.tree" | wc -l | tr -d ' ')"
if [ "$best_n" -lt 0 ] || [ "$n" -lt "$best_n" ]; then BASE="$c"; best_n="$n"; fi
[ "$n" -eq 0 ] && break
done
[ -n "$BASE" ] || die "无法反推基线,请用 --base 指定"
INFERRED=1
BASE_FROM="按内容反推(air-cargo 无 Synced-From 记录),需确认后以 --base 传入"
fi
S merge-base --is-ancestor "$BASE" "$TO" || die "基线 $(short "$BASE") 不是目标 $(short "$TO") 的祖先"
fi
# ---------- 计算变更清单 ----------
copy_list=() delete_list=() excluded_list=()
classify() { # $1=状态 $2=路径 $3=同步后文件是否应存在(1/0)
[ -z "$2" ] && return
if is_excluded "$2"; then excluded_list+=("$1|$2")
elif [ "$3" -eq 1 ]; then copy_list+=("$1|$2")
else delete_list+=("$1|$2")
fi
}
if [ "$WORKTREE" -eq 1 ]; then
# --no-renames:rename 拆成「删除旧路径 + 新增新路径」;--untracked-files=all:新目录逐个文件列出
while IFS= read -r -d '' entry; do
p="${entry:3}"
if [ -e "$SOURCE_DIR/$p" ] || [ -L "$SOURCE_DIR/$p" ]; then classify "${entry:0:2}" "$p" 1; else classify "${entry:0:2}" "$p" 0; fi
done < <(S status --porcelain=v1 -z --no-renames --untracked-files=all)
else
# git diff -z 的 name-status 输出为「状态\0路径\0」交替
while IFS= read -r -d '' st && IFS= read -r -d '' p; do
if [ "$st" = D ]; then classify "$st" "$p" 0; else classify "$st" "$p" 1; fi
done < <(S diff --name-status --no-renames -z "$BASE" "$TO")
fi
new_blob() { # 同步后该路径应有的内容
if [ "$WORKTREE" -eq 1 ]; then S hash-object -- "$1"; else blob_at "$TO" "$1"; fi
}
# 三方校验:air-cargo 现有内容既不等于基线、也不等于同步后的内容,说明 air-cargo 侧有独立修改,会被覆盖
conflict_list=()
for item in ${copy_list[@]+"${copy_list[@]}"} ${delete_list[@]+"${delete_list[@]}"}; do
st="${item%%|*}" p="${item#*|}"
[ -f "$TARGET_DIR/$p" ] || continue
tb="$(T hash-object -- "$p")"
[ "$tb" = "$(blob_at "$BASE" "$p")" ] && continue
[ "$st" != D ] && [ "$tb" = "$(new_blob "$p")" ] && continue
conflict_list+=("$p")
done
# air-cargo 工作区只允许存在本次同步范围内的改动(如上次同步未提交,重跑会覆盖为相同内容)
for item in ${copy_list[@]+"${copy_list[@]}"} ${delete_list[@]+"${delete_list[@]}"}; do
printf '%s\n' "${item#*|}"
done | LC_ALL=C sort -u > "$TMP/synced"
T status --porcelain=v1 -z --no-renames --untracked-files=all | tr '\0' '\n' | cut -c4- | LC_ALL=C sort -u > "$TMP/dirty"
unrelated="$(LC_ALL=C comm -23 "$TMP/dirty" "$TMP/synced")"
# ---------- 提交信息 ----------
printf '%s %s\n' "$LABEL" "$(date +%Y%m%d)" > "$TMP/msg"
[ "$WORKTREE" -eq 1 ] || printf '\nSynced-From: %s@%s\n' "$SOURCE_NAME" "$TO" >> "$TMP/msg"
# ---------- 打印计划 ----------
print_list() { # $1=标题 其余=「状态|路径」
local title="$1"; shift
echo ""
echo "=== ${title}($#)==="
for item in "$@"; do echo " [${item%%|*}] ${item#*|}"; done
}
echo "源仓库: $SOURCE_DIR"
echo "目标仓库: ${TARGET_DIR}(分支 ${TARGET_BRANCH})"
if [ "$WORKTREE" -eq 1 ]; then
echo "模式: 工作区模式(同步未提交变更,不推进同步点)"
else
echo "模式: 区间模式"
echo "基线: $(short "$BASE") $(S log -1 --format=%s "$BASE")"
echo " 来源:$BASE_FROM"
echo "目标: $(short "$TO") $(S log -1 --format=%s "$TO")(${TO_ARG:-$SOURCE_BRANCH})"
echo "区间提交数: $(S rev-list --count "$BASE..$TO")"
[ "$(S rev-parse HEAD)" = "$TO" ] || echo "提示: 源仓库 HEAD 不在目标提交上,同步内容以目标提交为准"
# 内容校验:同步点记录正确时,air-cargo HEAD 与基线应只有少量元文件差异
filtered_tree "$SOURCE_DIR" "$BASE" > "$TMP/base.tree"
diff_paths "$TMP/base.tree" "$TMP/target.tree" > "$TMP/verify"
vn="$(wc -l < "$TMP/verify" | tr -d ' ')"
echo ""
echo "=== 基线校验:air-cargo HEAD 与基线有 $vn 个文件内容不同 ==="
sed -n '1,20s/^/ /p' "$TMP/verify"
[ "$vn" -le 20 ] || echo " ……(其余 $((vn - 20)) 个省略)"
[ "$vn" -le 50 ] || echo " ⚠ 差异较大:基线可能不对,或 air-cargo 分支落后/不对,请先核实"
fi
if [ "$WORKTREE" -eq 0 ] && [ "$BASE" = "$TO" ]; then
echo ""
echo "air-cargo 已同步到 $(short "$TO"),无需同步。"
exit 0
fi
print_list "待复制/覆盖" ${copy_list[@]+"${copy_list[@]}"}
print_list "待删除" ${delete_list[@]+"${delete_list[@]}"}
print_list "已忽略,不参与同步" ${excluded_list[@]+"${excluded_list[@]}"}
echo ""
echo "=== ⚠ 将覆盖 air-cargo 侧独立修改(${#conflict_list[@]})==="
for p in ${conflict_list[@]+"${conflict_list[@]}"}; do echo " $p"; done
if [ -n "$unrelated" ]; then
echo ""
echo "=== ✖ air-cargo 工作区存在与本次同步无关的未提交改动 ==="
printf '%s\n' "$unrelated" | sed 's/^/ /'
fi
echo ""
echo "=== 提交信息 ==="
sed 's/^/ /' "$TMP/msg"
if [ "$MODE" != apply ]; then
echo ""
echo "(预览模式,未执行任何操作)"
exit 0
fi
# ---------- 执行 ----------
[ "$INFERRED" -eq 0 ] || die "基线为反推结果,确认无误后请用 --base $(short "$BASE") 重新执行"
[ -z "$unrelated" ] || die "air-cargo 工作区存在与本次同步无关的未提交改动,请先处理"
copied=0 deleted=0
stage_list=()
for item in ${copy_list[@]+"${copy_list[@]}"}; do
p="${item#*|}" dst="$TARGET_DIR/${item#*|}"
mkdir -p "$(dirname "$dst")"
rm -f "$dst"
if [ "$WORKTREE" -eq 1 ]; then
cp -f "$SOURCE_DIR/$p" "$dst"
else
mode="$(S ls-tree "$TO" -- "$p" | cut -d' ' -f1)"
case "$mode" in
120000) ln -s "$(S cat-file blob "$TO:$p")" "$dst" ;;
160000) echo "跳过子模块:$p" >&2; continue ;;
*) S cat-file blob "$TO:$p" > "$dst"; [ "$mode" != 100755 ] || chmod +x "$dst" ;;
esac
fi
stage_list+=("$p")
copied=$((copied + 1))
done
for item in ${delete_list[@]+"${delete_list[@]}"}; do
p="${item#*|}"
if [ -e "$TARGET_DIR/$p" ] || [ -L "$TARGET_DIR/$p" ]; then
rm -f "$TARGET_DIR/$p"
deleted=$((deleted + 1))
fi
# 只暂存 air-cargo 中已跟踪的路径,否则 git add 会因 pathspec 不匹配而失败
! T ls-files --error-unmatch -- "$p" > /dev/null 2>&1 || stage_list+=("$p")
done
[ ${#stage_list[@]} -eq 0 ] || printf '%s\0' "${stage_list[@]}" | T add -A --pathspec-from-file=- --pathspec-file-nul
echo ""
echo "同步完成:复制/更新 $copied 个文件,删除 $deleted 个文件。"
if T diff --cached --quiet; then
if [ "$WORKTREE" -eq 1 ]; then
echo "air-cargo 内容已一致,无需提交。"
exit 0
fi
# 区间内只有不参与同步的文件变化时,仍提交一个空提交推进同步点
T commit -q --allow-empty -F "$TMP/msg"
else
T commit -q -F "$TMP/msg"
fi
echo "已在 air-cargo 本地提交(未 push):$(T log -1 --format='%h %s')"
T show --stat --format= HEAD | tail -1